Skip to content

Commit 7141509

Browse files
committed
feat(lint): --changed, --ignore, and .pgsqllintrc.json config
1 parent b610587 commit 7141509

13 files changed

Lines changed: 1019 additions & 26 deletions

File tree

.agents/skills/pgsql-lint/SKILL.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,25 @@ pgsql-lint schema.sql --json # machine-readable
3636
pgsql-lint . --rules no-dynamic-sql # subset
3737
pgsql-lint . --warn require-qualified-refs # downgrade (won't fail)
3838
pgsql-lint . --off C2 # disable (id or code)
39+
pgsql-lint . --ignore 'sql/,**/generated/**' # exclude generated trees
40+
pgsql-lint --changed [base] # only .sql this branch touched
3941
```
4042

4143
Exit code is `1` when any **error**-severity, non-waived finding remains, `0`
4244
otherwise. `--warn` findings print but don't fail the run.
4345

46+
A repo states its policy once in `.pgsqllintrc.json` (discovered upward from cwd;
47+
`--config <file>` / `--no-config` override discovery) with the same keys as the
48+
flags — `rules`, `warn`, `off`, `ignore`, `keyword`, `paths`, plus `extends`
49+
naming another config file. Flags override the file. `paths` gives the default
50+
targets, so a CI step is just `pgsql-lint --changed`.
51+
52+
`--changed` diffs against `git merge-base HEAD <base>` (base: explicit →
53+
`$GITHUB_BASE_REF` → the repository's default branch), unions in working-tree and
54+
untracked changes, drops paths that no longer exist, and falls back to
55+
`git diff HEAD` on a shallow/detached checkout. Modelled on pgpm's bundle-drift
56+
check. Nothing changed → exit 0.
57+
4458
Programmatic entry points (all pure, DB-free):
4559

4660
```ts
@@ -139,5 +153,8 @@ Suppressed findings are reported as *acknowledged* accepted-risk, never dropped.
139153
| `src/rules/*` | the built-in C1–C4 rules |
140154
| `src/suppressions.ts` | the ESLint/Prettier-style directive parser |
141155
| `src/parse-unit.ts` | `CREATE FUNCTION``LintUnit` (SQL + PL/pgSQL bodies) |
156+
| `src/changed.ts` | `--changed` — merge-base + working-tree changed-file detection |
157+
| `src/config.ts` | `.pgsqllintrc.json` discovery, `extends`, key validation |
158+
| `src/ignore.ts` | `--ignore` gitignore-flavoured glob matching |
142159
| `src/cli.ts` | the `pgsql-lint` CLI |
143160
| `src/types.ts` | public types + `defineRule` |

packages/lint/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,49 @@ pgsql-lint . --rules no-dynamic-sql # only some rules
4646
pgsql-lint . --warn require-qualified-refs # downgrade to a warning (won't fail)
4747
pgsql-lint . --off C2 # disable a rule (by id or code)
4848
pgsql-lint . --json # machine-readable
49+
pgsql-lint . --ignore 'sql/,**/generated/**' # exclude generated trees
50+
pgsql-lint --changed # only .sql that this branch touched
51+
pgsql-lint --changed origin/main # …against an explicit base
4952
```
5053

5154
Exit code is `1` when any **error**-severity (and non-waived) finding remains,
5255
`0` otherwise — drop it straight into CI. `--warn` findings print but don't fail.
5356

57+
### Changed files only
58+
59+
`--changed[=<base>]` lints just the `.sql` files a branch touched, so a CI gate
60+
costs a second instead of scanning the whole tree. The base defaults to the pull
61+
request's base branch (`$GITHUB_BASE_REF`) and otherwise to the repository's
62+
default branch; the diff is taken against `git merge-base HEAD <base>`, so
63+
commits landed on the base branch afterwards don't widen the set. Uncommitted and
64+
untracked changes are included, deleted/renamed-away paths are dropped, and a
65+
shallow clone or detached checkout (no resolvable merge base) falls back to the
66+
working-tree diff against `HEAD`. Nothing changed is an exit-0 pass.
67+
68+
### Config file
69+
70+
`.pgsqllintrc.json`, discovered by walking up from the working directory (or
71+
passed with `--config <file>`; `--no-config` skips discovery). The keys mirror the
72+
flags, and any flag overrides the file:
73+
74+
```json
75+
{
76+
"extends": "./ci/lint-base.json",
77+
"paths": ["packages", "application/app"],
78+
"ignore": ["sql/", "application/constructive/", "**/generated/**"],
79+
"warn": ["require-qualified-refs"],
80+
"off": ["C2"]
81+
}
82+
```
83+
84+
`paths` supplies the default targets when none are given on the command line, so
85+
`pgsql-lint` and `pgsql-lint --changed` need no arguments. `extends` names another
86+
config *file* — a path relative to the file that declared it, or an npm module —
87+
and the inheriting file wins key by key. Ignore patterns are gitignore-flavoured
88+
globs relative to the config file's directory: `*` within a segment, `**` across
89+
segments, a plain path excludes the whole subtree, and an unanchored pattern
90+
matches at any segment boundary (`/` anchors it to the root).
91+
5492
## Suppressions
5593

5694
ESLint / Prettier-style comments, authored in the function body (they survive
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { execFileSync } from 'child_process';
2+
import * as fs from 'fs';
3+
import * as os from 'os';
4+
import * as path from 'path';
5+
6+
import { changedSqlFiles, resolveChangedBase } from '../src';
7+
8+
const CLEAN = `CREATE SCHEMA app_public;
9+
CREATE FUNCTION app_public.clean() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;
10+
`;
11+
12+
function git(cwd: string, ...args: string[]): void {
13+
execFileSync('git', args, { cwd, stdio: 'ignore' });
14+
}
15+
16+
/** A throwaway repo with one commit on `main`. */
17+
function repo(): string {
18+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'pgsql-lint-git-')));
19+
git(dir, 'init', '-q', '-b', 'main');
20+
git(dir, 'config', 'user.email', 'test@example.com');
21+
git(dir, 'config', 'user.name', 'test');
22+
fs.writeFileSync(path.join(dir, 'base.sql'), CLEAN);
23+
git(dir, 'add', '.');
24+
git(dir, 'commit', '-qm', 'base');
25+
return dir;
26+
}
27+
28+
describe('changedSqlFiles', () => {
29+
const saved = process.env.GITHUB_BASE_REF;
30+
beforeEach(() => {
31+
delete process.env.GITHUB_BASE_REF;
32+
});
33+
afterAll(() => {
34+
if (saved === undefined) delete process.env.GITHUB_BASE_REF;
35+
else process.env.GITHUB_BASE_REF = saved;
36+
});
37+
38+
it('returns nothing when the branch changed no SQL', () => {
39+
const dir = repo();
40+
expect(changedSqlFiles({ cwd: dir, base: 'main' }).files).toEqual([]);
41+
});
42+
43+
it('finds committed .sql changes against the merge base', () => {
44+
const dir = repo();
45+
git(dir, 'checkout', '-q', '-b', 'feature');
46+
fs.writeFileSync(path.join(dir, 'added.sql'), CLEAN);
47+
git(dir, 'add', '.');
48+
git(dir, 'commit', '-qm', 'add sql');
49+
50+
const result = changedSqlFiles({ cwd: dir, base: 'main' });
51+
expect(result.files).toEqual([path.join(dir, 'added.sql')]);
52+
expect(result.base).toBe('main');
53+
expect(result.mergeBase).toMatch(/^[0-9a-f]{40}$/);
54+
});
55+
56+
it('ignores commits made on the base branch after the merge base', () => {
57+
const dir = repo();
58+
git(dir, 'checkout', '-q', '-b', 'feature');
59+
fs.writeFileSync(path.join(dir, 'added.sql'), CLEAN);
60+
git(dir, 'add', '.');
61+
git(dir, 'commit', '-qm', 'add sql');
62+
63+
git(dir, 'checkout', '-q', 'main');
64+
fs.writeFileSync(path.join(dir, 'unrelated.sql'), CLEAN);
65+
git(dir, 'add', '.');
66+
git(dir, 'commit', '-qm', 'other work on main');
67+
git(dir, 'checkout', '-q', 'feature');
68+
69+
expect(changedSqlFiles({ cwd: dir, base: 'main' }).files).toEqual([
70+
path.join(dir, 'added.sql')
71+
]);
72+
});
73+
74+
it('includes uncommitted and untracked files', () => {
75+
const dir = repo();
76+
fs.writeFileSync(path.join(dir, 'untracked.sql'), CLEAN);
77+
fs.appendFileSync(path.join(dir, 'base.sql'), '\n-- edited\n');
78+
79+
expect(changedSqlFiles({ cwd: dir, base: 'main' }).files).toEqual([
80+
path.join(dir, 'base.sql'),
81+
path.join(dir, 'untracked.sql')
82+
]);
83+
});
84+
85+
it('drops deleted and renamed-away paths', () => {
86+
const dir = repo();
87+
git(dir, 'checkout', '-q', '-b', 'feature');
88+
git(dir, 'mv', 'base.sql', 'moved.sql');
89+
git(dir, 'commit', '-qm', 'move');
90+
91+
const files = changedSqlFiles({ cwd: dir, base: 'main' }).files;
92+
expect(files).toEqual([path.join(dir, 'moved.sql')]);
93+
});
94+
95+
it('filters out non-SQL changes', () => {
96+
const dir = repo();
97+
fs.writeFileSync(path.join(dir, 'notes.md'), '# hi\n');
98+
expect(changedSqlFiles({ cwd: dir, base: 'main' }).files).toEqual([]);
99+
});
100+
101+
it('falls back to the working-tree diff when the base does not exist', () => {
102+
const dir = repo();
103+
fs.writeFileSync(path.join(dir, 'untracked.sql'), CLEAN);
104+
const result = changedSqlFiles({ cwd: dir, base: 'origin/does-not-exist' });
105+
expect(result.mergeBase).toBeUndefined();
106+
expect(result.files).toEqual([path.join(dir, 'untracked.sql')]);
107+
});
108+
109+
it('throws outside a git repository', () => {
110+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgsql-lint-nogit-'));
111+
expect(() => changedSqlFiles({ cwd: dir })).toThrow(/needs a git repository/);
112+
});
113+
});
114+
115+
describe('resolveChangedBase', () => {
116+
const saved = process.env.GITHUB_BASE_REF;
117+
afterEach(() => {
118+
if (saved === undefined) delete process.env.GITHUB_BASE_REF;
119+
else process.env.GITHUB_BASE_REF = saved;
120+
});
121+
122+
it('prefers an explicit base', () => {
123+
process.env.GITHUB_BASE_REF = 'develop';
124+
expect(resolveChangedBase('release/1.0', repo())).toBe('release/1.0');
125+
});
126+
127+
it('uses the PR base branch in CI, unprefixed when no remote has it', () => {
128+
process.env.GITHUB_BASE_REF = 'develop';
129+
expect(resolveChangedBase(undefined, repo())).toBe('develop');
130+
});
131+
132+
it('falls back to the repository default branch', () => {
133+
delete process.env.GITHUB_BASE_REF;
134+
expect(resolveChangedBase(undefined, repo())).toBe('main');
135+
});
136+
});

0 commit comments

Comments
 (0)