@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/README.md +38 -0
- package/changed.d.ts +41 -0
- package/changed.js +159 -0
- package/cli.d.ts +6 -4
- package/cli.js +158 -16
- package/config.d.ts +50 -0
- package/config.js +157 -0
- package/esm/changed.js +121 -0
- package/esm/cli.js +125 -16
- package/esm/config.js +118 -0
- package/esm/file-runner.js +6 -5
- package/esm/ignore.js +101 -0
- package/esm/index.js +3 -0
- package/esm/linter.js +2 -1
- package/file-runner.d.ts +16 -3
- package/file-runner.js +6 -5
- package/ignore.d.ts +22 -0
- package/ignore.js +138 -0
- package/index.d.ts +6 -1
- package/index.js +13 -1
- package/linter.d.ts +4 -0
- package/linter.js +2 -1
- package/package.json +6 -6
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…> [--
|
|
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
|
|
9
|
-
* otherwise — so it drops straight into a
|
|
10
|
-
* downgraded with `--warn` print but never
|
|
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 (
|
|
59
|
+
if (v === undefined)
|
|
42
60
|
return undefined;
|
|
43
|
-
const parts =
|
|
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
|
|
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(
|
|
117
|
+
console.log(packageVersion());
|
|
54
118
|
return;
|
|
55
119
|
}
|
|
56
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|
package/esm/file-runner.js
CHANGED
|
@@ -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)));
|