@pgsql/lint 18.1.1 → 18.2.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/config.js ADDED
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ /**
3
+ * Config-file support — so a repository states its lint policy once, in a file,
4
+ * instead of repeating flags in every workflow step and every shell history.
5
+ *
6
+ * The keys mirror the CLI flags exactly (`rules`, `warn`, `off`, `ignore`,
7
+ * `keyword`, `paths`), and a flag always overrides the file. Discovery walks up
8
+ * from `cwd` looking for `.pgsqllintrc.json` / `.pgsqllintrc`.
9
+ *
10
+ * `extends` names another config *file* (a relative path resolved against the
11
+ * file that declared it, or a resolvable npm module). This package has no
12
+ * built-in presets — its rule set is injected as values, not discovered by name
13
+ * — so unlike safegres's `extends: "safegres:constructive"` there is nothing to
14
+ * name but a file.
15
+ */
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.CONFIG_FILENAMES = void 0;
51
+ exports.findConfigFile = findConfigFile;
52
+ exports.loadLintConfig = loadLintConfig;
53
+ exports.configDir = configDir;
54
+ const fs_1 = require("fs");
55
+ const path = __importStar(require("path"));
56
+ exports.CONFIG_FILENAMES = ['.pgsqllintrc.json', '.pgsqllintrc'];
57
+ const KNOWN_KEYS = new Set([
58
+ '$schema',
59
+ 'extends',
60
+ 'rules',
61
+ 'warn',
62
+ 'off',
63
+ 'ignore',
64
+ 'keyword',
65
+ 'paths'
66
+ ]);
67
+ /** Walk up from `cwd` for the first config file. */
68
+ function findConfigFile(cwd = process.cwd()) {
69
+ let dir = path.resolve(cwd);
70
+ for (;;) {
71
+ for (const name of exports.CONFIG_FILENAMES) {
72
+ const candidate = path.join(dir, name);
73
+ if ((0, fs_1.existsSync)(candidate))
74
+ return candidate;
75
+ }
76
+ const parent = path.dirname(dir);
77
+ if (parent === dir)
78
+ return undefined;
79
+ dir = parent;
80
+ }
81
+ }
82
+ function readConfigFile(file) {
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse((0, fs_1.readFileSync)(file, 'utf8'));
86
+ }
87
+ catch (err) {
88
+ throw new Error(`could not parse ${file}: ${err.message}`);
89
+ }
90
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
91
+ throw new Error(`${file}: expected a JSON object`);
92
+ }
93
+ const config = parsed;
94
+ const unknown = Object.keys(config).filter((k) => !KNOWN_KEYS.has(k));
95
+ if (unknown.length > 0) {
96
+ throw new Error(`${file}: unknown key(s) ${unknown.join(', ')} — expected any of ` +
97
+ `${[...KNOWN_KEYS].filter((k) => k !== '$schema').join(', ')}`);
98
+ }
99
+ return config;
100
+ }
101
+ /** Resolve an `extends` target: a path relative to `from`, or an npm module. */
102
+ function resolveExtends(target, from) {
103
+ const asPath = path.resolve(path.dirname(from), target);
104
+ for (const candidate of [asPath, `${asPath}.json`]) {
105
+ if ((0, fs_1.existsSync)(candidate))
106
+ return candidate;
107
+ }
108
+ try {
109
+ return require.resolve(target, { paths: [path.dirname(from)] });
110
+ }
111
+ catch {
112
+ throw new Error(`${from}: could not resolve "extends": ${target} — it is read as a path ` +
113
+ 'relative to the file that declared it, or as an npm module');
114
+ }
115
+ }
116
+ /** Later layers win; array keys replace rather than merge. */
117
+ function merge(base, over) {
118
+ const out = { ...base, ...over };
119
+ delete out.extends;
120
+ return out;
121
+ }
122
+ function expand(file, seen) {
123
+ if (seen.has(file))
124
+ throw new Error(`circular "extends" chain at ${file}`);
125
+ seen.add(file);
126
+ const config = readConfigFile(file);
127
+ const parents = config.extends
128
+ ? Array.isArray(config.extends)
129
+ ? config.extends
130
+ : [config.extends]
131
+ : [];
132
+ let acc = {};
133
+ for (const parent of parents) {
134
+ acc = merge(acc, expand(resolveExtends(parent, file), seen));
135
+ }
136
+ return merge(acc, config);
137
+ }
138
+ /**
139
+ * Load the effective config: an explicit `configFile`, else the nearest
140
+ * discovered one, else empty. Paths in `ignore`/`paths` stay as written — the
141
+ * caller resolves them against the config file's directory (see `configDir`).
142
+ */
143
+ function loadLintConfig(params = {}) {
144
+ const cwd = params.cwd ?? process.cwd();
145
+ const filepath = params.configFile
146
+ ? path.resolve(cwd, params.configFile)
147
+ : findConfigFile(cwd);
148
+ if (!filepath)
149
+ return { config: {} };
150
+ if (!(0, fs_1.existsSync)(filepath))
151
+ throw new Error(`config file not found: ${filepath}`);
152
+ return { config: expand(filepath, new Set()), filepath };
153
+ }
154
+ /** The directory a discovered config's relative paths resolve against. */
155
+ function configDir(loaded, cwd = process.cwd()) {
156
+ return loaded.filepath ? path.dirname(loaded.filepath) : cwd;
157
+ }
package/esm/changed.js ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Changed-file detection for `--changed`, so a CI gate (or a human before a
3
+ * commit) pays only for the SQL that a branch actually touched.
4
+ *
5
+ * Modelled on pgpm's bundle-drift check (`pgpm/core/src/packaging/check.ts` in
6
+ * constructive): resolve a base ref (explicit → `origin/$GITHUB_BASE_REF` in a
7
+ * PR → the repository's default branch), diff `HEAD` against the **merge base**
8
+ * so unrelated commits on the base branch don't widen the set, and union that
9
+ * with uncommitted/untracked working-tree changes. Deleted paths are dropped —
10
+ * there is nothing left on disk to lint.
11
+ */
12
+ import { execFileSync } from 'child_process';
13
+ import { existsSync, statSync } from 'fs';
14
+ import * as path from 'path';
15
+ function git(args, cwd) {
16
+ return execFileSync('git', args, {
17
+ cwd,
18
+ encoding: 'utf-8',
19
+ stdio: ['ignore', 'pipe', 'ignore'],
20
+ maxBuffer: 64 * 1024 * 1024
21
+ });
22
+ }
23
+ function tryGit(args, cwd) {
24
+ try {
25
+ return git(args, cwd);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** The repository's default branch as a remote-tracking ref, when discoverable. */
32
+ function defaultBranch(cwd) {
33
+ const head = tryGit(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], cwd);
34
+ if (head && head.trim())
35
+ return head.trim();
36
+ for (const candidate of ['origin/main', 'origin/master', 'main', 'master']) {
37
+ if (tryGit(['rev-parse', '--verify', '--quiet', candidate], cwd))
38
+ return candidate;
39
+ }
40
+ return undefined;
41
+ }
42
+ /**
43
+ * Resolve the ref to diff against. An explicit `base` wins; otherwise the PR
44
+ * base branch (`origin/$GITHUB_BASE_REF`) when running in GitHub Actions;
45
+ * otherwise the repository's default branch. `undefined` means "no base" and
46
+ * the caller falls back to working-tree changes only.
47
+ */
48
+ export function resolveChangedBase(base, cwd = process.cwd()) {
49
+ if (base && base.trim())
50
+ return base.trim();
51
+ const prBase = process.env.GITHUB_BASE_REF;
52
+ if (prBase && prBase.trim()) {
53
+ const ref = `origin/${prBase.trim()}`;
54
+ if (tryGit(['rev-parse', '--verify', '--quiet', ref], cwd))
55
+ return ref;
56
+ return prBase.trim();
57
+ }
58
+ return defaultBranch(cwd);
59
+ }
60
+ /** Parse `git status --porcelain` into paths (rename target wins). */
61
+ function workingTreePaths(cwd) {
62
+ const out = [];
63
+ // `-uall` lists untracked *files*; the default collapses a new directory to
64
+ // the directory name, which would hide every file a new module adds.
65
+ const status = tryGit(['status', '--porcelain', '-uall'], cwd) ?? '';
66
+ for (const rawLine of status.split('\n')) {
67
+ const line = rawLine.trimEnd();
68
+ if (!line)
69
+ continue;
70
+ let p = line.slice(3);
71
+ const arrow = p.indexOf(' -> ');
72
+ if (arrow !== -1)
73
+ p = p.slice(arrow + 4);
74
+ p = p.replace(/^"|"$/g, '');
75
+ if (p)
76
+ out.push(p);
77
+ }
78
+ return out;
79
+ }
80
+ /**
81
+ * Collect the files that differ from `base` (via `git merge-base`) plus any
82
+ * uncommitted/untracked working-tree changes. Falls back to `git diff HEAD`
83
+ * when no base is resolvable or no merge base exists — a shallow clone or a
84
+ * detached CI checkout — rather than failing the run.
85
+ */
86
+ export function changedFiles(options = {}) {
87
+ const cwd = options.cwd ?? process.cwd();
88
+ if (!tryGit(['rev-parse', '--git-dir'], cwd)) {
89
+ throw new Error(`--changed needs a git repository; ${cwd} is not inside one`);
90
+ }
91
+ const files = new Set(workingTreePaths(cwd));
92
+ const base = resolveChangedBase(options.base, cwd);
93
+ let mergeBase;
94
+ if (base) {
95
+ const found = tryGit(['merge-base', 'HEAD', base], cwd);
96
+ mergeBase = found?.trim() || undefined;
97
+ }
98
+ // No base, or no common ancestor (shallow clone / detached checkout): the
99
+ // uncommitted diff against HEAD is all the history we can see.
100
+ const diffArgs = mergeBase
101
+ ? ['diff', '--name-only', '--diff-filter=ACMR', mergeBase, 'HEAD']
102
+ : ['diff', '--name-only', '--diff-filter=ACMR', 'HEAD'];
103
+ for (const rawLine of (tryGit(diffArgs, cwd) ?? '').split('\n')) {
104
+ const p = rawLine.trim();
105
+ if (p)
106
+ files.add(p);
107
+ }
108
+ const abs = [];
109
+ for (const rel of files) {
110
+ const full = path.resolve(cwd, rel);
111
+ // Deleted or renamed-away paths have nothing left to lint.
112
+ if (existsSync(full) && statSync(full).isFile())
113
+ abs.push(full);
114
+ }
115
+ return { files: abs.sort(), base, mergeBase };
116
+ }
117
+ /** {@link changedFiles}, narrowed to `.sql`. */
118
+ export function changedSqlFiles(options = {}) {
119
+ const result = changedFiles(options);
120
+ return { ...result, files: result.files.filter((f) => f.toLowerCase().endsWith('.sql')) };
121
+ }
package/esm/cli.js CHANGED
@@ -2,15 +2,21 @@
2
2
  /**
3
3
  * `pgsql-lint` — lint SQL source files for the convention rules.
4
4
  *
5
- * pgsql-lint <path…> [--rules a,b] [--warn a,b] [--off a,b] [--json] [--quiet]
5
+ * pgsql-lint <path…> [--changed[=<base>]] [--ignore <globs>] [--rules a,b]
6
+ * [--warn a,b] [--off a,b] [--config <file>] [--json] [--quiet]
6
7
  *
7
8
  * Paths may be files or directories (directories are scanned recursively for
8
- * `.sql`). Exit code is 1 when any *error*-severity finding remains, 0
9
- * otherwise — so it drops straight into a pre-commit hook or CI step. Findings
10
- * downgraded with `--warn` print but never fail the run.
9
+ * `.sql`), and default to `paths` from the config file. Exit code is 1 when any
10
+ * *error*-severity finding remains, 0 otherwise — so it drops straight into a
11
+ * pre-commit hook or CI step. Findings downgraded with `--warn` print but never
12
+ * fail the run.
11
13
  */
12
14
  import chalk from 'chalk';
15
+ import { existsSync } from 'fs';
13
16
  import minimist from 'minimist';
17
+ import * as path from 'path';
18
+ import { changedSqlFiles } from './changed';
19
+ import { configDir, loadLintConfig } from './config';
14
20
  import { lintFiles } from './file-runner';
15
21
  import { LINT_RULES } from './rules';
16
22
  const HELP = `pgsql-lint — source-level SQL/PL/pgSQL convention linter
@@ -20,17 +26,29 @@ Usage:
20
26
 
21
27
  Arguments:
22
28
  path One or more .sql files or directories (scanned recursively).
29
+ Defaults to "paths" from the config file.
23
30
 
24
31
  Options:
32
+ --changed[=<base>] Lint only .sql files that differ from <base> (default: the
33
+ PR base branch, else the repository default branch), using
34
+ the merge base, plus working-tree changes. Exits 0 when
35
+ nothing changed.
36
+ --ignore <globs> Exclude paths (comma-separated, repeatable).
25
37
  --rules <ids> Comma-separated rule ids/codes to run (default: all).
26
38
  --warn <ids> Report these rules as warnings (do not fail the run).
27
39
  --off <ids> Disable these rules entirely.
28
40
  --keyword <kw> Suppression directive keyword (default: pgsql-lint,safegres).
41
+ --config <file> Config file to use (default: nearest .pgsqllintrc.json).
42
+ --no-config Ignore any config file.
29
43
  --json Emit findings as JSON.
30
44
  --quiet Only print active findings (hide acknowledged waivers).
31
45
  -h, --help Show this help.
32
46
  -v, --version Show version.
33
47
 
48
+ Config file (.pgsqllintrc.json, discovered upward from cwd; flags override it):
49
+ { "extends": "./base.json", "rules": [], "warn": [], "off": [],
50
+ "ignore": ["sql/", "**/generated/**"], "keyword": [], "paths": ["packages"] }
51
+
34
52
  Rules:
35
53
  ${LINT_RULES.map((r) => ` ${r.code} ${r.id} — ${r.title}`).join('\n')}
36
54
 
@@ -38,33 +56,114 @@ Suppress inline (in the function body):
38
56
  -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: <why>
39
57
  `;
40
58
  function csv(v) {
41
- if (!v)
59
+ if (v === undefined)
42
60
  return undefined;
43
- const parts = v.split(',').map((s) => s.trim()).filter(Boolean);
61
+ const parts = (Array.isArray(v) ? v : [v])
62
+ .flatMap((s) => s.split(','))
63
+ .map((s) => s.trim())
64
+ .filter(Boolean);
44
65
  return parts.length > 0 ? parts : undefined;
45
66
  }
67
+ /**
68
+ * Own version. In the repo the compiled CLI sits in `dist/`, but the published
69
+ * package flattens `dist/` to its root, so `../package.json` only resolves in
70
+ * one of the two layouts — try both rather than crashing on `--version`.
71
+ */
72
+ function packageVersion() {
73
+ for (const candidate of ['../package.json', './package.json']) {
74
+ try {
75
+ return require(candidate).version;
76
+ }
77
+ catch {
78
+ // Wrong layout; try the next candidate.
79
+ }
80
+ }
81
+ return 'unknown';
82
+ }
83
+ /**
84
+ * `--changed` takes an *optional* value, which minimist cannot express: with
85
+ * `string: ['changed']` it would swallow a following path. Bind the next token
86
+ * only when it is not a flag and not an existing path — i.e. when it reads as a
87
+ * git ref — and otherwise rewrite the flag to an empty value.
88
+ */
89
+ function normalizeChanged(args) {
90
+ const out = [];
91
+ for (let i = 0; i < args.length; i++) {
92
+ if (args[i] !== '--changed') {
93
+ out.push(args[i]);
94
+ continue;
95
+ }
96
+ const next = args[i + 1];
97
+ if (next && !next.startsWith('-') && !existsSync(next)) {
98
+ out.push(`--changed=${next}`);
99
+ i++;
100
+ }
101
+ else {
102
+ out.push('--changed=');
103
+ }
104
+ }
105
+ return out;
106
+ }
46
107
  async function main() {
47
- const argv = minimist(process.argv.slice(2), {
108
+ const raw = process.argv.slice(2);
109
+ // Handled here rather than as a minimist boolean: `config` also takes a value.
110
+ const noConfig = raw.includes('--no-config');
111
+ const argv = minimist(normalizeChanged(raw.filter((a) => a !== '--no-config')), {
48
112
  boolean: ['json', 'quiet', 'help', 'version'],
49
- string: ['rules', 'warn', 'off', 'keyword'],
113
+ string: ['rules', 'warn', 'off', 'keyword', 'changed', 'ignore', 'config'],
50
114
  alias: { h: 'help', v: 'version' }
51
115
  });
52
116
  if (argv.version) {
53
- console.log(require('../package.json').version);
117
+ console.log(packageVersion());
54
118
  return;
55
119
  }
56
- if (argv.help || argv._.length === 0) {
120
+ const cwd = process.cwd();
121
+ const loaded = noConfig
122
+ ? { config: {} }
123
+ : loadLintConfig({ cwd, configFile: argv.config || undefined });
124
+ const fileConfig = loaded.config;
125
+ const baseDir = configDir(loaded, cwd);
126
+ const useChanged = argv.changed !== undefined;
127
+ const paths = argv._.length > 0
128
+ ? argv._
129
+ : (fileConfig.paths ?? []).map((p) => path.resolve(baseDir, p));
130
+ if (argv.help || (!useChanged && paths.length === 0)) {
57
131
  console.log(HELP);
58
- process.exit(argv._.length === 0 && !argv.help ? 1 : 0);
132
+ process.exit(argv.help ? 0 : 1);
59
133
  }
60
- const rules = csv(argv.rules);
61
- const keyword = csv(argv.keyword);
134
+ const rules = csv(argv.rules) ?? fileConfig.rules;
135
+ const keyword = csv(argv.keyword) ?? (fileConfig.keyword ? csv(fileConfig.keyword) : undefined);
136
+ const ignore = csv(argv.ignore) ?? fileConfig.ignore;
62
137
  const severity = {};
63
- for (const id of csv(argv.warn) ?? [])
138
+ for (const id of csv(argv.warn) ?? fileConfig.warn ?? [])
64
139
  severity[id] = 'warn';
65
- for (const id of csv(argv.off) ?? [])
140
+ for (const id of csv(argv.off) ?? fileConfig.off ?? [])
66
141
  severity[id] = 'off';
67
- const reports = await lintFiles(argv._, { rules, keyword, severity });
142
+ let targets;
143
+ if (useChanged) {
144
+ const changed = changedSqlFiles({ cwd, base: argv.changed || undefined });
145
+ // Nothing to lint is a pass, not an error — the common case on a branch
146
+ // that touched no SQL at all.
147
+ if (changed.files.length === 0) {
148
+ if (argv.json)
149
+ console.log('[]');
150
+ else
151
+ console.log(chalk.green('0 errors'), chalk.gray('(no changed .sql files)'));
152
+ process.exit(0);
153
+ }
154
+ targets = paths.length > 0 ? withinPaths(changed.files, paths) : changed.files;
155
+ if (targets.length === 0) {
156
+ if (argv.json)
157
+ console.log('[]');
158
+ else
159
+ console.log(chalk.green('0 errors'), chalk.gray('(no changed .sql files under the given paths)'));
160
+ process.exit(0);
161
+ }
162
+ }
163
+ else {
164
+ targets = paths;
165
+ }
166
+ const reports = await lintFiles(targets, { rules, keyword, severity, ignore, cwd: baseDir });
68
167
  if (argv.json) {
69
168
  console.log(JSON.stringify(reports, null, 2));
70
169
  }
@@ -104,6 +203,16 @@ async function main() {
104
203
  }
105
204
  process.exit(errors > 0 ? 1 : 0);
106
205
  }
206
+ /** Keep only changed files that sit inside one of the requested paths. */
207
+ function withinPaths(files, paths) {
208
+ const roots = paths.map((p) => path.resolve(p));
209
+ return files.filter((file) => roots.some((root) => {
210
+ if (file === root)
211
+ return true;
212
+ const rel = path.relative(root, file);
213
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
214
+ }));
215
+ }
107
216
  function formatFinding(f) {
108
217
  const loc = chalk.gray(`${String(f.line).padStart(4)}`);
109
218
  const tag = f.acknowledged
package/esm/config.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Config-file support — so a repository states its lint policy once, in a file,
3
+ * instead of repeating flags in every workflow step and every shell history.
4
+ *
5
+ * The keys mirror the CLI flags exactly (`rules`, `warn`, `off`, `ignore`,
6
+ * `keyword`, `paths`), and a flag always overrides the file. Discovery walks up
7
+ * from `cwd` looking for `.pgsqllintrc.json` / `.pgsqllintrc`.
8
+ *
9
+ * `extends` names another config *file* (a relative path resolved against the
10
+ * file that declared it, or a resolvable npm module). This package has no
11
+ * built-in presets — its rule set is injected as values, not discovered by name
12
+ * — so unlike safegres's `extends: "safegres:constructive"` there is nothing to
13
+ * name but a file.
14
+ */
15
+ import { existsSync, readFileSync } from 'fs';
16
+ import * as path from 'path';
17
+ export const CONFIG_FILENAMES = ['.pgsqllintrc.json', '.pgsqllintrc'];
18
+ const KNOWN_KEYS = new Set([
19
+ '$schema',
20
+ 'extends',
21
+ 'rules',
22
+ 'warn',
23
+ 'off',
24
+ 'ignore',
25
+ 'keyword',
26
+ 'paths'
27
+ ]);
28
+ /** Walk up from `cwd` for the first config file. */
29
+ export function findConfigFile(cwd = process.cwd()) {
30
+ let dir = path.resolve(cwd);
31
+ for (;;) {
32
+ for (const name of CONFIG_FILENAMES) {
33
+ const candidate = path.join(dir, name);
34
+ if (existsSync(candidate))
35
+ return candidate;
36
+ }
37
+ const parent = path.dirname(dir);
38
+ if (parent === dir)
39
+ return undefined;
40
+ dir = parent;
41
+ }
42
+ }
43
+ function readConfigFile(file) {
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
47
+ }
48
+ catch (err) {
49
+ throw new Error(`could not parse ${file}: ${err.message}`);
50
+ }
51
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
52
+ throw new Error(`${file}: expected a JSON object`);
53
+ }
54
+ const config = parsed;
55
+ const unknown = Object.keys(config).filter((k) => !KNOWN_KEYS.has(k));
56
+ if (unknown.length > 0) {
57
+ throw new Error(`${file}: unknown key(s) ${unknown.join(', ')} — expected any of ` +
58
+ `${[...KNOWN_KEYS].filter((k) => k !== '$schema').join(', ')}`);
59
+ }
60
+ return config;
61
+ }
62
+ /** Resolve an `extends` target: a path relative to `from`, or an npm module. */
63
+ function resolveExtends(target, from) {
64
+ const asPath = path.resolve(path.dirname(from), target);
65
+ for (const candidate of [asPath, `${asPath}.json`]) {
66
+ if (existsSync(candidate))
67
+ return candidate;
68
+ }
69
+ try {
70
+ return require.resolve(target, { paths: [path.dirname(from)] });
71
+ }
72
+ catch {
73
+ throw new Error(`${from}: could not resolve "extends": ${target} — it is read as a path ` +
74
+ 'relative to the file that declared it, or as an npm module');
75
+ }
76
+ }
77
+ /** Later layers win; array keys replace rather than merge. */
78
+ function merge(base, over) {
79
+ const out = { ...base, ...over };
80
+ delete out.extends;
81
+ return out;
82
+ }
83
+ function expand(file, seen) {
84
+ if (seen.has(file))
85
+ throw new Error(`circular "extends" chain at ${file}`);
86
+ seen.add(file);
87
+ const config = readConfigFile(file);
88
+ const parents = config.extends
89
+ ? Array.isArray(config.extends)
90
+ ? config.extends
91
+ : [config.extends]
92
+ : [];
93
+ let acc = {};
94
+ for (const parent of parents) {
95
+ acc = merge(acc, expand(resolveExtends(parent, file), seen));
96
+ }
97
+ return merge(acc, config);
98
+ }
99
+ /**
100
+ * Load the effective config: an explicit `configFile`, else the nearest
101
+ * discovered one, else empty. Paths in `ignore`/`paths` stay as written — the
102
+ * caller resolves them against the config file's directory (see `configDir`).
103
+ */
104
+ export function loadLintConfig(params = {}) {
105
+ const cwd = params.cwd ?? process.cwd();
106
+ const filepath = params.configFile
107
+ ? path.resolve(cwd, params.configFile)
108
+ : findConfigFile(cwd);
109
+ if (!filepath)
110
+ return { config: {} };
111
+ if (!existsSync(filepath))
112
+ throw new Error(`config file not found: ${filepath}`);
113
+ return { config: expand(filepath, new Set()), filepath };
114
+ }
115
+ /** The directory a discovered config's relative paths resolve against. */
116
+ export function configDir(loaded, cwd = process.cwd()) {
117
+ return loaded.filepath ? path.dirname(loaded.filepath) : cwd;
118
+ }
@@ -13,6 +13,7 @@ import { promises as fs } from 'fs';
13
13
  import * as path from 'path';
14
14
  import { parse } from 'pgsql-parser';
15
15
  import { lintDefinition } from './engine';
16
+ import { applyIgnore } from './ignore';
16
17
  import { findAll } from './walk';
17
18
  /** Count newlines before `offset` — the 0-based line the offset sits on. */
18
19
  function lineAtOffset(text, offset) {
@@ -154,7 +155,7 @@ async function sqlFilesUnder(dir) {
154
155
  return out;
155
156
  }
156
157
  /** Resolve a mix of file and directory paths to a sorted list of `.sql` files. */
157
- export async function resolveSqlFiles(paths) {
158
+ export async function resolveSqlFiles(paths, options = {}) {
158
159
  const files = new Set();
159
160
  for (const p of paths) {
160
161
  const st = await fs.stat(p);
@@ -166,11 +167,11 @@ export async function resolveSqlFiles(paths) {
166
167
  files.add(p);
167
168
  }
168
169
  }
169
- return [...files].sort();
170
+ return applyIgnore([...files].sort(), options.ignore, options.cwd);
170
171
  }
171
172
  /** Lint every `.sql` file reachable from `paths` (files or directories). */
172
173
  export async function lintFiles(paths, options = {}) {
173
- const files = await resolveSqlFiles(paths);
174
+ const files = await resolveSqlFiles(paths, { ignore: options.ignore, cwd: options.cwd });
174
175
  const reports = [];
175
176
  for (const file of files) {
176
177
  const source = await fs.readFile(file, 'utf8');
@@ -184,11 +185,11 @@ export function sqlTextAdapter(source, file) {
184
185
  return { id: 'sql-text', definitions: () => sqlTextDefinitions(source, file) };
185
186
  }
186
187
  /** A {@link SourceAdapter} over `.sql` files/directories on disk. */
187
- export function filesAdapter(paths) {
188
+ export function filesAdapter(paths, options = {}) {
188
189
  return {
189
190
  id: 'files',
190
191
  definitions: async () => {
191
- const files = await resolveSqlFiles(paths);
192
+ const files = await resolveSqlFiles(paths, options);
192
193
  const out = [];
193
194
  for (const file of files) {
194
195
  out.push(...(await sqlTextDefinitions(await fs.readFile(file, 'utf8'), file)));