@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 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: LintRule, severity?: SeverityMap): Severity;
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 produces no lint findings — dynamic/opaque bodies
37
- // are a downstream concern (e.g. a call-graph), not the linter's.
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 produces no lint findings — dynamic/opaque bodies
33
- // are a downstream concern (e.g. a call-graph), not the linter's.
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;
@@ -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 ?? source.length - start;
65
+ const len = entry.stmt_len ?? bytes.length - start;
70
66
  out.push({
71
- text: source.slice(start, start + len),
67
+ text: sliceBytes(bytes, start, len),
72
68
  language: funcLanguage(createFnStmt),
73
69
  name: funcName(createFnStmt),
74
70
  file,
75
- startLine: lineAtOffset(source, start) // 0-based line the definition begins on
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
- return { file: '<source>', findings: [], parseError: err.message };
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
- /** Count newlines in `s[0..offset)` — i.e. how many lines precede `offset`. */
15
- function newlinesBefore(s, offset) {
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, s.length);
18
+ const end = Math.min(offset, text.length);
18
19
  for (let i = 0; i < end; i++)
19
- if (s.charCodeAt(i) === 10)
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 + newlinesBefore(body, offset)
132
+ lineForOffset: (offset) => bodyStartLine + newlinesBeforeByte(bodyBytes, offset)
135
133
  });
136
134
  }
137
135
  catch {
package/esm/util.js CHANGED
@@ -1,9 +1,31 @@
1
- /** Absolute 1-based line of a char offset within `text`. */
2
- export function lineOfOffset(text, offset) {
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(offset, 0), text.length);
22
+ const end = Math.min(Math.max(byteOffset, 0), bytes.length);
5
23
  for (let i = 0; i < end; i++)
6
- if (text.charCodeAt(i) === 10)
24
+ if (bytes[i] === 10)
7
25
  n++;
8
- return n + 1;
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 ?? source.length - start;
107
+ const len = entry.stmt_len ?? bytes.length - start;
112
108
  out.push({
113
- text: source.slice(start, start + len),
109
+ text: (0, util_1.sliceBytes)(bytes, start, len),
114
110
  language: funcLanguage(createFnStmt),
115
111
  name: funcName(createFnStmt),
116
112
  file,
117
- startLine: lineAtOffset(source, start) // 0-based line the definition begins on
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
- return { file: '<source>', findings: [], parseError: err.message };
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",
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.2",
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.2"
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": "d49ec05b871e46de411a2af1630c234d4a2bb38f"
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
- /** Count newlines in `s[0..offset)` — i.e. how many lines precede `offset`. */
18
- function newlinesBefore(s, offset) {
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, s.length);
21
+ const end = Math.min(offset, text.length);
21
22
  for (let i = 0; i < end; i++)
22
- if (s.charCodeAt(i) === 10)
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 + newlinesBefore(body, offset)
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
- /** Absolute 1-based line of a char offset within `text`. */
2
- export declare function lineOfOffset(text: string, offset: number): number;
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
- /** Absolute 1-based line of a char offset within `text`. */
5
- function lineOfOffset(text, offset) {
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(offset, 0), text.length);
28
+ const end = Math.min(Math.max(byteOffset, 0), bytes.length);
8
29
  for (let i = 0; i < end; i++)
9
- if (text.charCodeAt(i) === 10)
30
+ if (bytes[i] === 10)
10
31
  n++;
11
- return n + 1;
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
  }