Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .agents/skills/pgsql-lint/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,25 @@ pgsql-lint schema.sql --json # machine-readable
pgsql-lint . --rules no-dynamic-sql # subset
pgsql-lint . --warn require-qualified-refs # downgrade (won't fail)
pgsql-lint . --off C2 # disable (id or code)
pgsql-lint . --ignore 'sql/,**/generated/**' # exclude generated trees
pgsql-lint --changed [base] # only .sql this branch touched
```

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

A repo states its policy once in `.pgsqllintrc.json` (discovered upward from cwd;
`--config <file>` / `--no-config` override discovery) with the same keys as the
flags — `rules`, `warn`, `off`, `ignore`, `keyword`, `paths`, plus `extends`
naming another config file. Flags override the file. `paths` gives the default
targets, so a CI step is just `pgsql-lint --changed`.

`--changed` diffs against `git merge-base HEAD <base>` (base: explicit →
`$GITHUB_BASE_REF` → the repository's default branch), unions in working-tree and
untracked changes, drops paths that no longer exist, and falls back to
`git diff HEAD` on a shallow/detached checkout. Modelled on pgpm's bundle-drift
check. Nothing changed → exit 0.

Programmatic entry points (all pure, DB-free):

```ts
Expand Down Expand Up @@ -139,5 +153,8 @@ Suppressed findings are reported as *acknowledged* accepted-risk, never dropped.
| `src/rules/*` | the built-in C1–C4 rules |
| `src/suppressions.ts` | the ESLint/Prettier-style directive parser |
| `src/parse-unit.ts` | `CREATE FUNCTION` → `LintUnit` (SQL + PL/pgSQL bodies) |
| `src/changed.ts` | `--changed` — merge-base + working-tree changed-file detection |
| `src/config.ts` | `.pgsqllintrc.json` discovery, `extends`, key validation |
| `src/ignore.ts` | `--ignore` gitignore-flavoured glob matching |
| `src/cli.ts` | the `pgsql-lint` CLI |
| `src/types.ts` | public types + `defineRule` |
38 changes: 38 additions & 0 deletions packages/lint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,49 @@ pgsql-lint . --rules no-dynamic-sql # only some rules
pgsql-lint . --warn require-qualified-refs # downgrade to a warning (won't fail)
pgsql-lint . --off C2 # disable a rule (by id or code)
pgsql-lint . --json # machine-readable
pgsql-lint . --ignore 'sql/,**/generated/**' # exclude generated trees
pgsql-lint --changed # only .sql that this branch touched
pgsql-lint --changed origin/main # …against an explicit base
```

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

### Changed files only

`--changed[=<base>]` lints just the `.sql` files a branch touched, so a CI gate
costs a second instead of scanning the whole tree. The base defaults to the pull
request's base branch (`$GITHUB_BASE_REF`) and otherwise to the repository's
default branch; the diff is taken against `git merge-base HEAD <base>`, so
commits landed on the base branch afterwards don't widen the set. Uncommitted and
untracked changes are included, deleted/renamed-away paths are dropped, and a
shallow clone or detached checkout (no resolvable merge base) falls back to the
working-tree diff against `HEAD`. Nothing changed is an exit-0 pass.

### Config file

`.pgsqllintrc.json`, discovered by walking up from the working directory (or
passed with `--config <file>`; `--no-config` skips discovery). The keys mirror the
flags, and any flag overrides the file:

```json
{
"extends": "./ci/lint-base.json",
"paths": ["packages", "application/app"],
"ignore": ["sql/", "application/constructive/", "**/generated/**"],
"warn": ["require-qualified-refs"],
"off": ["C2"]
}
```

`paths` supplies the default targets when none are given on the command line, so
`pgsql-lint` and `pgsql-lint --changed` need no arguments. `extends` names another
config *file* — a path relative to the file that declared it, or an npm module —
and the inheriting file wins key by key. Ignore patterns are gitignore-flavoured
globs relative to the config file's directory: `*` within a segment, `**` across
segments, a plain path excludes the whole subtree, and an unanchored pattern
matches at any segment boundary (`/` anchors it to the root).

## Suppressions

ESLint / Prettier-style comments, authored in the function body (they survive
Expand Down
136 changes: 136 additions & 0 deletions packages/lint/__tests__/changed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

import { changedSqlFiles, resolveChangedBase } from '../src';

const CLEAN = `CREATE SCHEMA app_public;
CREATE FUNCTION app_public.clean() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;
`;

function git(cwd: string, ...args: string[]): void {
execFileSync('git', args, { cwd, stdio: 'ignore' });
}

/** A throwaway repo with one commit on `main`. */
function repo(): string {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'pgsql-lint-git-')));
git(dir, 'init', '-q', '-b', 'main');
git(dir, 'config', 'user.email', 'test@example.com');
git(dir, 'config', 'user.name', 'test');
fs.writeFileSync(path.join(dir, 'base.sql'), CLEAN);
git(dir, 'add', '.');
git(dir, 'commit', '-qm', 'base');
return dir;
}

describe('changedSqlFiles', () => {
const saved = process.env.GITHUB_BASE_REF;
beforeEach(() => {
delete process.env.GITHUB_BASE_REF;
});
afterAll(() => {
if (saved === undefined) delete process.env.GITHUB_BASE_REF;
else process.env.GITHUB_BASE_REF = saved;
});

it('returns nothing when the branch changed no SQL', () => {
const dir = repo();
expect(changedSqlFiles({ cwd: dir, base: 'main' }).files).toEqual([]);
});

it('finds committed .sql changes against the merge base', () => {
const dir = repo();
git(dir, 'checkout', '-q', '-b', 'feature');
fs.writeFileSync(path.join(dir, 'added.sql'), CLEAN);
git(dir, 'add', '.');
git(dir, 'commit', '-qm', 'add sql');

const result = changedSqlFiles({ cwd: dir, base: 'main' });
expect(result.files).toEqual([path.join(dir, 'added.sql')]);
expect(result.base).toBe('main');
expect(result.mergeBase).toMatch(/^[0-9a-f]{40}$/);
});

it('ignores commits made on the base branch after the merge base', () => {
const dir = repo();
git(dir, 'checkout', '-q', '-b', 'feature');
fs.writeFileSync(path.join(dir, 'added.sql'), CLEAN);
git(dir, 'add', '.');
git(dir, 'commit', '-qm', 'add sql');

git(dir, 'checkout', '-q', 'main');
fs.writeFileSync(path.join(dir, 'unrelated.sql'), CLEAN);
git(dir, 'add', '.');
git(dir, 'commit', '-qm', 'other work on main');
git(dir, 'checkout', '-q', 'feature');

expect(changedSqlFiles({ cwd: dir, base: 'main' }).files).toEqual([
path.join(dir, 'added.sql')
]);
});

it('includes uncommitted and untracked files', () => {
const dir = repo();
fs.writeFileSync(path.join(dir, 'untracked.sql'), CLEAN);
fs.appendFileSync(path.join(dir, 'base.sql'), '\n-- edited\n');

expect(changedSqlFiles({ cwd: dir, base: 'main' }).files).toEqual([
path.join(dir, 'base.sql'),
path.join(dir, 'untracked.sql')
]);
});

it('drops deleted and renamed-away paths', () => {
const dir = repo();
git(dir, 'checkout', '-q', '-b', 'feature');
git(dir, 'mv', 'base.sql', 'moved.sql');
git(dir, 'commit', '-qm', 'move');

const files = changedSqlFiles({ cwd: dir, base: 'main' }).files;
expect(files).toEqual([path.join(dir, 'moved.sql')]);
});

it('filters out non-SQL changes', () => {
const dir = repo();
fs.writeFileSync(path.join(dir, 'notes.md'), '# hi\n');
expect(changedSqlFiles({ cwd: dir, base: 'main' }).files).toEqual([]);
});

it('falls back to the working-tree diff when the base does not exist', () => {
const dir = repo();
fs.writeFileSync(path.join(dir, 'untracked.sql'), CLEAN);
const result = changedSqlFiles({ cwd: dir, base: 'origin/does-not-exist' });
expect(result.mergeBase).toBeUndefined();
expect(result.files).toEqual([path.join(dir, 'untracked.sql')]);
});

it('throws outside a git repository', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgsql-lint-nogit-'));
expect(() => changedSqlFiles({ cwd: dir })).toThrow(/needs a git repository/);
});
});

describe('resolveChangedBase', () => {
const saved = process.env.GITHUB_BASE_REF;
afterEach(() => {
if (saved === undefined) delete process.env.GITHUB_BASE_REF;
else process.env.GITHUB_BASE_REF = saved;
});

it('prefers an explicit base', () => {
process.env.GITHUB_BASE_REF = 'develop';
expect(resolveChangedBase('release/1.0', repo())).toBe('release/1.0');
});

it('uses the PR base branch in CI, unprefixed when no remote has it', () => {
process.env.GITHUB_BASE_REF = 'develop';
expect(resolveChangedBase(undefined, repo())).toBe('develop');
});

it('falls back to the repository default branch', () => {
delete process.env.GITHUB_BASE_REF;
expect(resolveChangedBase(undefined, repo())).toBe('main');
});
});
Loading
Loading