diff --git a/.agents/skills/pgsql-lint/SKILL.md b/.agents/skills/pgsql-lint/SKILL.md index 893fd138..172c7a42 100644 --- a/.agents/skills/pgsql-lint/SKILL.md +++ b/.agents/skills/pgsql-lint/SKILL.md @@ -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 ` / `--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: 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 @@ -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` | diff --git a/packages/lint/README.md b/packages/lint/README.md index 6288e918..29cdc903 100644 --- a/packages/lint/README.md +++ b/packages/lint/README.md @@ -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[=]` 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 `, 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 `; `--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 diff --git a/packages/lint/__tests__/changed.test.ts b/packages/lint/__tests__/changed.test.ts new file mode 100644 index 00000000..4e951f57 --- /dev/null +++ b/packages/lint/__tests__/changed.test.ts @@ -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'); + }); +}); diff --git a/packages/lint/__tests__/cli.test.ts b/packages/lint/__tests__/cli.test.ts index 86ae7a25..ac84d2f9 100644 --- a/packages/lint/__tests__/cli.test.ts +++ b/packages/lint/__tests__/cli.test.ts @@ -1,5 +1,6 @@ -import { execFile } from 'child_process'; +import { execFile, execFileSync } from 'child_process'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { promisify } from 'util'; @@ -7,6 +8,9 @@ const execFileAsync = promisify(execFile); const CLI = path.join(__dirname, '..', 'dist', 'cli.js'); const FIXTURES = path.join(__dirname, '__fixtures__'); +const DIRTY = fs.existsSync(path.join(FIXTURES, 'migration.sql')) + ? fs.readFileSync(path.join(FIXTURES, 'migration.sql'), 'utf8') + : ''; interface RunResult { code: number; @@ -14,9 +18,9 @@ interface RunResult { stderr: string; } -async function runCli(args: string[]): Promise { +async function runCli(args: string[], cwd?: string): Promise { try { - const { stdout, stderr } = await execFileAsync('node', [CLI, ...args]); + const { stdout, stderr } = await execFileAsync('node', [CLI, ...args], { cwd }); return { code: 0, stdout, stderr }; } catch (err) { const e = err as { code?: number; stdout?: string; stderr?: string }; @@ -24,6 +28,28 @@ async function runCli(args: string[]): Promise { } } +function git(cwd: string, ...args: string[]): void { + execFileSync('git', args, { cwd, stdio: 'ignore' }); +} + +/** The files a JSON run reported findings for. */ +function withFindings(stdout: string): string[] { + const reports = JSON.parse(stdout) as Array<{ file: string; findings: unknown[] }>; + return reports.filter((r) => r.findings.length > 0).map((r) => r.file); +} + +/** A throwaway repo with one clean commit on `main`. */ +function repo(): string { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'pgsql-lint-cli-'))); + 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'), '-- nothing to lint\n'); + git(dir, 'add', '.'); + git(dir, 'commit', '-qm', 'base'); + return dir; +} + // The CLI is exercised against the built dist/ (CI runs `pnpm build` first). const built = fs.existsSync(CLI); const describeIfBuilt = built ? describe : describe.skip; @@ -80,3 +106,127 @@ describeIfBuilt('pgsql-lint CLI', () => { expect(stdout).toContain('no-dynamic-sql'); }); }); + +describeIfBuilt('pgsql-lint CLI --ignore', () => { + it('excludes an ignored directory', async () => { + const dir = repo(); + fs.mkdirSync(path.join(dir, 'sql')); + fs.writeFileSync(path.join(dir, 'sql', 'generated.sql'), DIRTY); + + expect((await runCli(['.', '--quiet'], dir)).code).toBe(1); + const { code, stdout } = await runCli(['.', '--ignore', 'sql/'], dir); + expect(code).toBe(0); + expect(stdout).toContain('0 errors'); + }); + + it('accepts a comma-separated list and a repeated flag', async () => { + const dir = repo(); + fs.mkdirSync(path.join(dir, 'sql')); + fs.mkdirSync(path.join(dir, 'gen')); + fs.writeFileSync(path.join(dir, 'sql', 'a.sql'), DIRTY); + fs.writeFileSync(path.join(dir, 'gen', 'b.sql'), DIRTY); + + expect((await runCli(['.', '--ignore', 'sql/,gen/'], dir)).code).toBe(0); + expect((await runCli(['.', '--ignore', 'sql/', '--ignore', 'gen/'], dir)).code).toBe(0); + expect((await runCli(['.', '--ignore', 'sql/'], dir)).code).toBe(1); + }); +}); + +describeIfBuilt('pgsql-lint CLI --changed', () => { + it('exits 0 with no findings when nothing changed', async () => { + const dir = repo(); + const { code, stdout } = await runCli(['--changed', 'main'], dir); + expect(code).toBe(0); + expect(stdout).toContain('no changed .sql files'); + }); + + it('lints only the changed file', async () => { + const dir = repo(); + fs.writeFileSync(path.join(dir, 'new.sql'), DIRTY); + const { code, stdout } = await runCli(['--changed', 'main', '--json'], dir); + expect(code).toBe(1); + const reports = JSON.parse(stdout); + expect(reports).toHaveLength(1); + expect(reports[0].file).toBe(path.join(dir, 'new.sql')); + }); + + it('auto-detects the base with no argument', async () => { + const dir = repo(); + git(dir, 'checkout', '-q', '-b', 'feature'); + fs.writeFileSync(path.join(dir, 'new.sql'), DIRTY); + git(dir, 'add', '.'); + git(dir, 'commit', '-qm', 'add'); + + const { code, stdout } = await runCli(['--changed', '--json'], dir); + expect(code).toBe(1); + expect(JSON.parse(stdout)).toHaveLength(1); + }); + + it('does not swallow a following path argument', async () => { + const dir = repo(); + fs.mkdirSync(path.join(dir, 'pkg')); + fs.writeFileSync(path.join(dir, 'pkg', 'a.sql'), DIRTY); + fs.writeFileSync(path.join(dir, 'other.sql'), DIRTY); + + // `--changed pkg` — `pkg` is a path, so it scopes the changed set. + const { code, stdout } = await runCli(['--changed', 'pkg', '--json'], dir); + expect(code).toBe(1); + const reports = JSON.parse(stdout); + expect(reports).toHaveLength(1); + expect(reports[0].file).toBe(path.join(dir, 'pkg', 'a.sql')); + }); + + it('honours --ignore over the changed set', async () => { + const dir = repo(); + fs.mkdirSync(path.join(dir, 'sql')); + fs.writeFileSync(path.join(dir, 'sql', 'generated.sql'), DIRTY); + expect((await runCli(['--changed', 'main'], dir)).code).toBe(1); + expect((await runCli(['--changed', 'main', '--ignore', 'sql/'], dir)).code).toBe(0); + }); +}); + +describeIfBuilt('pgsql-lint CLI config file', () => { + it('applies ignore, paths, and warn from .pgsqllintrc.json', async () => { + const dir = repo(); + fs.mkdirSync(path.join(dir, 'sql')); + fs.mkdirSync(path.join(dir, 'pkg')); + fs.writeFileSync(path.join(dir, 'sql', 'generated.sql'), DIRTY); + fs.writeFileSync(path.join(dir, 'pkg', 'hand-written.sql'), DIRTY); + fs.writeFileSync( + path.join(dir, '.pgsqllintrc.json'), + JSON.stringify({ ignore: ['sql/'], paths: ['.'] }) + ); + + const withConfig = await runCli(['--json'], dir); + expect(withConfig.code).toBe(1); + expect(withFindings(withConfig.stdout)).toEqual([path.join(dir, 'pkg', 'hand-written.sql')]); + + // Same tree, config ignored: the generated file is linted too. + const ignored = await runCli(['.', '--no-config', '--json'], dir); + // Paths are reported as given, so a positional `.` yields relative paths. + expect(withFindings(ignored.stdout)).toEqual([ + path.join('pkg', 'hand-written.sql'), + path.join('sql', 'generated.sql') + ]); + }); + + it('downgrades to a warning from the config file, and a flag overrides it', async () => { + const dir = repo(); + fs.writeFileSync(path.join(dir, 'a.sql'), DIRTY); + fs.writeFileSync( + path.join(dir, '.pgsqllintrc.json'), + JSON.stringify({ warn: ['require-qualified-refs'], paths: ['.'] }) + ); + expect((await runCli([], dir)).code).toBe(0); + // An explicit --warn replaces the config's list, so C3 fails again. + expect((await runCli(['--warn', 'C4'], dir)).code).toBe(1); + }); + + it('exits 2 with a readable error on a bad config file', async () => { + const dir = repo(); + fs.writeFileSync(path.join(dir, '.pgsqllintrc.json'), '{ "nope": true }'); + const { code, stderr } = await runCli(['.'], dir); + expect(code).toBe(2); + expect(stderr).toContain('unknown key'); + }); +}); diff --git a/packages/lint/__tests__/config.test.ts b/packages/lint/__tests__/config.test.ts new file mode 100644 index 00000000..eef8f87f --- /dev/null +++ b/packages/lint/__tests__/config.test.ts @@ -0,0 +1,83 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { configDir, findConfigFile, loadLintConfig } from '../src'; + +function tmpdir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'pgsql-lint-config-')); +} + +function write(dir: string, name: string, body: unknown): string { + const file = path.join(dir, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(body, null, 2)); + return file; +} + +describe('loadLintConfig', () => { + it('returns an empty config when no file exists', () => { + const dir = tmpdir(); + expect(loadLintConfig({ cwd: dir })).toEqual({ config: {} }); + }); + + it('discovers .pgsqllintrc.json by walking up from cwd', () => { + const dir = tmpdir(); + write(dir, '.pgsqllintrc.json', { ignore: ['sql/'], paths: ['packages'] }); + const nested = path.join(dir, 'a', 'b'); + fs.mkdirSync(nested, { recursive: true }); + + const loaded = loadLintConfig({ cwd: nested }); + expect(loaded.filepath).toBe(path.join(dir, '.pgsqllintrc.json')); + expect(loaded.config.ignore).toEqual(['sql/']); + expect(loaded.config.paths).toEqual(['packages']); + expect(configDir(loaded, nested)).toBe(dir); + expect(findConfigFile(nested)).toBe(path.join(dir, '.pgsqllintrc.json')); + }); + + it('reads an explicit config file', () => { + const dir = tmpdir(); + write(dir, 'ci/lint.json', { off: ['C2'] }); + const loaded = loadLintConfig({ cwd: dir, configFile: 'ci/lint.json' }); + expect(loaded.config.off).toEqual(['C2']); + expect(configDir(loaded, dir)).toBe(path.join(dir, 'ci')); + }); + + it('resolves extends against the declaring file and lets the child win', () => { + const dir = tmpdir(); + write(dir, 'base.json', { ignore: ['sql/'], warn: ['C3'], keyword: ['pgsql-lint'] }); + write(dir, '.pgsqllintrc.json', { extends: './base.json', warn: ['C4'] }); + + const { config } = loadLintConfig({ cwd: dir }); + expect(config.ignore).toEqual(['sql/']); + expect(config.keyword).toEqual(['pgsql-lint']); + expect(config.warn).toEqual(['C4']); + expect(config.extends).toBeUndefined(); + }); + + it('throws on an unresolvable extends target', () => { + const dir = tmpdir(); + write(dir, '.pgsqllintrc.json', { extends: './nope.json' }); + expect(() => loadLintConfig({ cwd: dir })).toThrow(/could not resolve "extends"/); + }); + + it('throws on a circular extends chain', () => { + const dir = tmpdir(); + write(dir, 'a.json', { extends: './b.json' }); + write(dir, 'b.json', { extends: './a.json' }); + write(dir, '.pgsqllintrc.json', { extends: './a.json' }); + expect(() => loadLintConfig({ cwd: dir })).toThrow(/circular/); + }); + + it('rejects unknown keys', () => { + const dir = tmpdir(); + write(dir, '.pgsqllintrc.json', { rulez: ['C1'] }); + expect(() => loadLintConfig({ cwd: dir })).toThrow(/unknown key\(s\) rulez/); + }); + + it('reports a parse error with the file name', () => { + const dir = tmpdir(); + fs.writeFileSync(path.join(dir, '.pgsqllintrc.json'), '{ not json'); + expect(() => loadLintConfig({ cwd: dir })).toThrow(/could not parse/); + }); +}); diff --git a/packages/lint/__tests__/ignore.test.ts b/packages/lint/__tests__/ignore.test.ts new file mode 100644 index 00000000..f65d2e7a --- /dev/null +++ b/packages/lint/__tests__/ignore.test.ts @@ -0,0 +1,58 @@ +import * as path from 'path'; + +import { applyIgnore, makeIgnoreFilter } from '../src'; + +const CWD = '/repo'; +const p = (rel: string): string => path.join(CWD, rel); + +describe('makeIgnoreFilter', () => { + it('excludes a whole subtree from a plain directory pattern', () => { + const ignored = makeIgnoreFilter(['sql/'], CWD); + expect(ignored(p('sql/app--1.0.0.sql'))).toBe(true); + expect(ignored(p('sql/nested/deep.sql'))).toBe(true); + expect(ignored(p('packages/x/deploy/a.sql'))).toBe(false); + }); + + it('treats a slashless plain path the same as a directory', () => { + const ignored = makeIgnoreFilter(['application/constructive'], CWD); + expect(ignored(p('application/constructive/deploy/a.sql'))).toBe(true); + expect(ignored(p('application/app/deploy/a.sql'))).toBe(false); + }); + + it('matches an unanchored pattern at any segment boundary', () => { + const ignored = makeIgnoreFilter(['generated/'], CWD); + expect(ignored(p('packages/x/generated/y.sql'))).toBe(true); + expect(ignored(p('generated/y.sql'))).toBe(true); + }); + + it('anchors a leading-slash pattern to cwd', () => { + const ignored = makeIgnoreFilter(['/sql/'], CWD); + expect(ignored(p('sql/a.sql'))).toBe(true); + expect(ignored(p('packages/x/sql/a.sql'))).toBe(false); + }); + + it('supports * within a segment and ** across segments', () => { + const ignored = makeIgnoreFilter(['**/testing/*-seed/**', '*.gen.sql'], CWD); + expect(ignored(p('testing/rls-seed/deploy/a.sql'))).toBe(true); + expect(ignored(p('a/b/testing/simple-seed/x.sql'))).toBe(true); + expect(ignored(p('testing/other/x.sql'))).toBe(false); + expect(ignored(p('packages/x/schema.gen.sql'))).toBe(true); + expect(ignored(p('packages/x/schema.sql'))).toBe(false); + }); + + it('matches zero segments for a leading **/', () => { + const ignored = makeIgnoreFilter(['**/sql/**'], CWD); + expect(ignored(p('sql/a.sql'))).toBe(true); + expect(ignored(p('packages/x/sql/a.sql'))).toBe(true); + }); + + it('ignores nothing when no patterns are given', () => { + expect(makeIgnoreFilter([], CWD)(p('a.sql'))).toBe(false); + expect(applyIgnore([p('a.sql')], undefined, CWD)).toEqual([p('a.sql')]); + }); + + it('filters a file list', () => { + const files = [p('sql/a.sql'), p('packages/x/deploy/b.sql')]; + expect(applyIgnore(files, ['sql/'], CWD)).toEqual([p('packages/x/deploy/b.sql')]); + }); +}); diff --git a/packages/lint/src/changed.ts b/packages/lint/src/changed.ts new file mode 100644 index 00000000..31165272 --- /dev/null +++ b/packages/lint/src/changed.ts @@ -0,0 +1,131 @@ +/** + * Changed-file detection for `--changed`, so a CI gate (or a human before a + * commit) pays only for the SQL that a branch actually touched. + * + * Modelled on pgpm's bundle-drift check (`pgpm/core/src/packaging/check.ts` in + * constructive): resolve a base ref (explicit → `origin/$GITHUB_BASE_REF` in a + * PR → the repository's default branch), diff `HEAD` against the **merge base** + * so unrelated commits on the base branch don't widen the set, and union that + * with uncommitted/untracked working-tree changes. Deleted paths are dropped — + * there is nothing left on disk to lint. + */ + +import { execFileSync } from 'child_process'; +import { existsSync, statSync } from 'fs'; +import * as path from 'path'; + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + maxBuffer: 64 * 1024 * 1024 + }); +} + +function tryGit(args: string[], cwd: string): string | null { + try { + return git(args, cwd); + } catch { + return null; + } +} + +/** The repository's default branch as a remote-tracking ref, when discoverable. */ +function defaultBranch(cwd: string): string | undefined { + const head = tryGit(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], cwd); + if (head && head.trim()) return head.trim(); + for (const candidate of ['origin/main', 'origin/master', 'main', 'master']) { + if (tryGit(['rev-parse', '--verify', '--quiet', candidate], cwd)) return candidate; + } + return undefined; +} + +/** + * Resolve the ref to diff against. An explicit `base` wins; otherwise the PR + * base branch (`origin/$GITHUB_BASE_REF`) when running in GitHub Actions; + * otherwise the repository's default branch. `undefined` means "no base" and + * the caller falls back to working-tree changes only. + */ +export function resolveChangedBase(base?: string, cwd: string = process.cwd()): string | undefined { + if (base && base.trim()) return base.trim(); + const prBase = process.env.GITHUB_BASE_REF; + if (prBase && prBase.trim()) { + const ref = `origin/${prBase.trim()}`; + if (tryGit(['rev-parse', '--verify', '--quiet', ref], cwd)) return ref; + return prBase.trim(); + } + return defaultBranch(cwd); +} + +export interface ChangedFilesResult { + /** Absolute paths of changed files that still exist on disk. */ + files: string[]; + /** The base ref used, if any. */ + base?: string; + /** The merge base actually diffed against, if one was resolvable. */ + mergeBase?: string; +} + +/** Parse `git status --porcelain` into paths (rename target wins). */ +function workingTreePaths(cwd: string): string[] { + const out: string[] = []; + // `-uall` lists untracked *files*; the default collapses a new directory to + // the directory name, which would hide every file a new module adds. + const status = tryGit(['status', '--porcelain', '-uall'], cwd) ?? ''; + for (const rawLine of status.split('\n')) { + const line = rawLine.trimEnd(); + if (!line) continue; + let p = line.slice(3); + const arrow = p.indexOf(' -> '); + if (arrow !== -1) p = p.slice(arrow + 4); + p = p.replace(/^"|"$/g, ''); + if (p) out.push(p); + } + return out; +} + +/** + * Collect the files that differ from `base` (via `git merge-base`) plus any + * uncommitted/untracked working-tree changes. Falls back to `git diff HEAD` + * when no base is resolvable or no merge base exists — a shallow clone or a + * detached CI checkout — rather than failing the run. + */ +export function changedFiles(options: { cwd?: string; base?: string } = {}): ChangedFilesResult { + const cwd = options.cwd ?? process.cwd(); + if (!tryGit(['rev-parse', '--git-dir'], cwd)) { + throw new Error(`--changed needs a git repository; ${cwd} is not inside one`); + } + + const files = new Set(workingTreePaths(cwd)); + const base = resolveChangedBase(options.base, cwd); + let mergeBase: string | undefined; + + if (base) { + const found = tryGit(['merge-base', 'HEAD', base], cwd); + mergeBase = found?.trim() || undefined; + } + // No base, or no common ancestor (shallow clone / detached checkout): the + // uncommitted diff against HEAD is all the history we can see. + const diffArgs = mergeBase + ? ['diff', '--name-only', '--diff-filter=ACMR', mergeBase, 'HEAD'] + : ['diff', '--name-only', '--diff-filter=ACMR', 'HEAD']; + for (const rawLine of (tryGit(diffArgs, cwd) ?? '').split('\n')) { + const p = rawLine.trim(); + if (p) files.add(p); + } + + const abs: string[] = []; + for (const rel of files) { + const full = path.resolve(cwd, rel); + // Deleted or renamed-away paths have nothing left to lint. + if (existsSync(full) && statSync(full).isFile()) abs.push(full); + } + return { files: abs.sort(), base, mergeBase }; +} + +/** {@link changedFiles}, narrowed to `.sql`. */ +export function changedSqlFiles(options: { cwd?: string; base?: string } = {}): ChangedFilesResult { + const result = changedFiles(options); + return { ...result, files: result.files.filter((f) => f.toLowerCase().endsWith('.sql')) }; +} diff --git a/packages/lint/src/cli.ts b/packages/lint/src/cli.ts index a63673d7..23bc84a2 100644 --- a/packages/lint/src/cli.ts +++ b/packages/lint/src/cli.ts @@ -2,27 +2,36 @@ /** * `pgsql-lint` — lint SQL source files for the convention rules. * - * pgsql-lint [--rules a,b] [--warn a,b] [--off a,b] [--json] [--quiet] + * pgsql-lint [--changed[=]] [--ignore ] [--rules a,b] + * [--warn a,b] [--off a,b] [--config ] [--json] [--quiet] * * Paths may be files or directories (directories are scanned recursively for - * `.sql`). Exit code is 1 when any *error*-severity finding remains, 0 - * otherwise — so it drops straight into a pre-commit hook or CI step. Findings - * downgraded with `--warn` print but never fail the run. + * `.sql`), and default to `paths` from the config file. Exit code is 1 when any + * *error*-severity finding remains, 0 otherwise — so it drops straight into a + * pre-commit hook or CI step. Findings downgraded with `--warn` print but never + * fail the run. */ import chalk from 'chalk'; +import { existsSync } from 'fs'; import minimist from 'minimist'; +import * as path from 'path'; +import { changedSqlFiles } from './changed'; +import { configDir, loadLintConfig } from './config'; import { FileFinding, lintFiles } from './file-runner'; import { LINT_RULES } from './rules'; import type { SeverityMap } from './types'; interface Argv { _: string[]; + changed?: string; + ignore?: string | string[]; rules?: string; warn?: string; off?: string; keyword?: string; + config?: string; json?: boolean; quiet?: boolean; help?: boolean; @@ -36,17 +45,29 @@ Usage: Arguments: path One or more .sql files or directories (scanned recursively). + Defaults to "paths" from the config file. Options: + --changed[=] Lint only .sql files that differ from (default: the + PR base branch, else the repository default branch), using + the merge base, plus working-tree changes. Exits 0 when + nothing changed. + --ignore Exclude paths (comma-separated, repeatable). --rules Comma-separated rule ids/codes to run (default: all). --warn Report these rules as warnings (do not fail the run). --off Disable these rules entirely. --keyword Suppression directive keyword (default: pgsql-lint,safegres). + --config Config file to use (default: nearest .pgsqllintrc.json). + --no-config Ignore any config file. --json Emit findings as JSON. --quiet Only print active findings (hide acknowledged waivers). -h, --help Show this help. -v, --version Show version. +Config file (.pgsqllintrc.json, discovered upward from cwd; flags override it): + { "extends": "./base.json", "rules": [], "warn": [], "off": [], + "ignore": ["sql/", "**/generated/**"], "keyword": [], "paths": ["packages"] } + Rules: ${LINT_RULES.map((r) => ` ${r.code} ${r.id} — ${r.title}`).join('\n')} @@ -54,36 +75,116 @@ Suppress inline (in the function body): -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: `; -function csv(v: string | undefined): string[] | undefined { - if (!v) return undefined; - const parts = v.split(',').map((s) => s.trim()).filter(Boolean); +function csv(v: string | string[] | undefined): string[] | undefined { + if (v === undefined) return undefined; + const parts = (Array.isArray(v) ? v : [v]) + .flatMap((s) => s.split(',')) + .map((s) => s.trim()) + .filter(Boolean); return parts.length > 0 ? parts : undefined; } +/** + * Own version. In the repo the compiled CLI sits in `dist/`, but the published + * package flattens `dist/` to its root, so `../package.json` only resolves in + * one of the two layouts — try both rather than crashing on `--version`. + */ +function packageVersion(): string { + for (const candidate of ['../package.json', './package.json']) { + try { + return (require(candidate) as { version: string }).version; + } catch { + // Wrong layout; try the next candidate. + } + } + return 'unknown'; +} + +/** + * `--changed` takes an *optional* value, which minimist cannot express: with + * `string: ['changed']` it would swallow a following path. Bind the next token + * only when it is not a flag and not an existing path — i.e. when it reads as a + * git ref — and otherwise rewrite the flag to an empty value. + */ +function normalizeChanged(args: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] !== '--changed') { + out.push(args[i]); + continue; + } + const next = args[i + 1]; + if (next && !next.startsWith('-') && !existsSync(next)) { + out.push(`--changed=${next}`); + i++; + } else { + out.push('--changed='); + } + } + return out; +} + async function main(): Promise { - const argv = minimist(process.argv.slice(2), { + const raw = process.argv.slice(2); + // Handled here rather than as a minimist boolean: `config` also takes a value. + const noConfig = raw.includes('--no-config'); + const argv = minimist(normalizeChanged(raw.filter((a) => a !== '--no-config')), { boolean: ['json', 'quiet', 'help', 'version'], - string: ['rules', 'warn', 'off', 'keyword'], + string: ['rules', 'warn', 'off', 'keyword', 'changed', 'ignore', 'config'], alias: { h: 'help', v: 'version' } }) as unknown as Argv; if (argv.version) { - - console.log(require('../package.json').version); + console.log(packageVersion()); return; } - if (argv.help || argv._.length === 0) { + + const cwd = process.cwd(); + const loaded = noConfig + ? { config: {} } + : loadLintConfig({ cwd, configFile: argv.config || undefined }); + const fileConfig = loaded.config; + const baseDir = configDir(loaded, cwd); + + const useChanged = argv.changed !== undefined; + const paths = + argv._.length > 0 + ? argv._ + : (fileConfig.paths ?? []).map((p) => path.resolve(baseDir, p)); + + if (argv.help || (!useChanged && paths.length === 0)) { console.log(HELP); - process.exit(argv._.length === 0 && !argv.help ? 1 : 0); + process.exit(argv.help ? 0 : 1); } - const rules = csv(argv.rules); - const keyword = csv(argv.keyword); + const rules = csv(argv.rules) ?? fileConfig.rules; + const keyword = csv(argv.keyword) ?? (fileConfig.keyword ? csv(fileConfig.keyword) : undefined); + const ignore = csv(argv.ignore) ?? fileConfig.ignore; const severity: SeverityMap = {}; - for (const id of csv(argv.warn) ?? []) severity[id] = 'warn'; - for (const id of csv(argv.off) ?? []) severity[id] = 'off'; + for (const id of csv(argv.warn) ?? fileConfig.warn ?? []) severity[id] = 'warn'; + for (const id of csv(argv.off) ?? fileConfig.off ?? []) severity[id] = 'off'; + + let targets: string[]; + if (useChanged) { + const changed = changedSqlFiles({ cwd, base: argv.changed || undefined }); + // Nothing to lint is a pass, not an error — the common case on a branch + // that touched no SQL at all. + if (changed.files.length === 0) { + if (argv.json) console.log('[]'); + else console.log(chalk.green('0 errors'), chalk.gray('(no changed .sql files)')); + process.exit(0); + } + targets = paths.length > 0 ? withinPaths(changed.files, paths) : changed.files; + if (targets.length === 0) { + if (argv.json) console.log('[]'); + else console.log(chalk.green('0 errors'), chalk.gray('(no changed .sql files under the given paths)')); + process.exit(0); + } + } else { + targets = paths; + } - const reports = await lintFiles(argv._, { rules, keyword, severity }); + const reports = await lintFiles(targets, { rules, keyword, severity, ignore, cwd: baseDir }); if (argv.json) { console.log(JSON.stringify(reports, null, 2)); @@ -121,6 +222,18 @@ async function main(): Promise { process.exit(errors > 0 ? 1 : 0); } +/** Keep only changed files that sit inside one of the requested paths. */ +function withinPaths(files: string[], paths: string[]): string[] { + const roots = paths.map((p) => path.resolve(p)); + return files.filter((file) => + roots.some((root) => { + if (file === root) return true; + const rel = path.relative(root, file); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); + }) + ); +} + function formatFinding(f: FileFinding): string { const loc = chalk.gray(`${String(f.line).padStart(4)}`); const tag = f.acknowledged diff --git a/packages/lint/src/config.ts b/packages/lint/src/config.ts new file mode 100644 index 00000000..549de3e6 --- /dev/null +++ b/packages/lint/src/config.ts @@ -0,0 +1,150 @@ +/** + * Config-file support — so a repository states its lint policy once, in a file, + * instead of repeating flags in every workflow step and every shell history. + * + * The keys mirror the CLI flags exactly (`rules`, `warn`, `off`, `ignore`, + * `keyword`, `paths`), and a flag always overrides the file. Discovery walks up + * from `cwd` looking for `.pgsqllintrc.json` / `.pgsqllintrc`. + * + * `extends` names another config *file* (a relative path resolved against the + * file that declared it, or a resolvable npm module). This package has no + * built-in presets — its rule set is injected as values, not discovered by name + * — so unlike safegres's `extends: "safegres:constructive"` there is nothing to + * name but a file. + */ + +import { existsSync, readFileSync } from 'fs'; +import * as path from 'path'; + +/** The shape of `.pgsqllintrc.json`. Every key mirrors a CLI flag. */ +export interface LintConfigFile { + /** Another config file to inherit from (path, or npm module). */ + extends?: string | string[]; + /** Rule ids/codes to run (default: all). */ + rules?: string[]; + /** Rules reported as warnings. */ + warn?: string[]; + /** Rules disabled entirely. */ + off?: string[]; + /** Glob patterns to exclude. */ + ignore?: string[]; + /** Suppression directive keyword(s). */ + keyword?: string | string[]; + /** Default paths to lint when none are given on the command line. */ + paths?: string[]; +} + +export const CONFIG_FILENAMES = ['.pgsqllintrc.json', '.pgsqllintrc'] as const; + +const KNOWN_KEYS = new Set([ + '$schema', + 'extends', + 'rules', + 'warn', + 'off', + 'ignore', + 'keyword', + 'paths' +]); + +/** Walk up from `cwd` for the first config file. */ +export function findConfigFile(cwd: string = process.cwd()): string | undefined { + let dir = path.resolve(cwd); + for (;;) { + for (const name of CONFIG_FILENAMES) { + const candidate = path.join(dir, name); + if (existsSync(candidate)) return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +function readConfigFile(file: string): LintConfigFile { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(file, 'utf8')); + } catch (err) { + throw new Error(`could not parse ${file}: ${(err as Error).message}`); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${file}: expected a JSON object`); + } + const config = parsed as Record; + const unknown = Object.keys(config).filter((k) => !KNOWN_KEYS.has(k)); + if (unknown.length > 0) { + throw new Error( + `${file}: unknown key(s) ${unknown.join(', ')} — expected any of ` + + `${[...KNOWN_KEYS].filter((k) => k !== '$schema').join(', ')}` + ); + } + return config as LintConfigFile; +} + +/** Resolve an `extends` target: a path relative to `from`, or an npm module. */ +function resolveExtends(target: string, from: string): string { + const asPath = path.resolve(path.dirname(from), target); + for (const candidate of [asPath, `${asPath}.json`]) { + if (existsSync(candidate)) return candidate; + } + try { + return require.resolve(target, { paths: [path.dirname(from)] }); + } catch { + throw new Error( + `${from}: could not resolve "extends": ${target} — it is read as a path ` + + 'relative to the file that declared it, or as an npm module' + ); + } +} + +/** Later layers win; array keys replace rather than merge. */ +function merge(base: LintConfigFile, over: LintConfigFile): LintConfigFile { + const out: LintConfigFile = { ...base, ...over }; + delete out.extends; + return out; +} + +function expand(file: string, seen: Set): LintConfigFile { + if (seen.has(file)) throw new Error(`circular "extends" chain at ${file}`); + seen.add(file); + const config = readConfigFile(file); + const parents = config.extends + ? Array.isArray(config.extends) + ? config.extends + : [config.extends] + : []; + let acc: LintConfigFile = {}; + for (const parent of parents) { + acc = merge(acc, expand(resolveExtends(parent, file), seen)); + } + return merge(acc, config); +} + +export interface LoadedLintConfig { + config: LintConfigFile; + /** The file the config was read from, when one was found. */ + filepath?: string; +} + +/** + * Load the effective config: an explicit `configFile`, else the nearest + * discovered one, else empty. Paths in `ignore`/`paths` stay as written — the + * caller resolves them against the config file's directory (see `configDir`). + */ +export function loadLintConfig( + params: { cwd?: string; configFile?: string } = {} +): LoadedLintConfig { + const cwd = params.cwd ?? process.cwd(); + const filepath = params.configFile + ? path.resolve(cwd, params.configFile) + : findConfigFile(cwd); + if (!filepath) return { config: {} }; + if (!existsSync(filepath)) throw new Error(`config file not found: ${filepath}`); + return { config: expand(filepath, new Set()), filepath }; +} + +/** The directory a discovered config's relative paths resolve against. */ +export function configDir(loaded: LoadedLintConfig, cwd: string = process.cwd()): string { + return loaded.filepath ? path.dirname(loaded.filepath) : cwd; +} diff --git a/packages/lint/src/file-runner.ts b/packages/lint/src/file-runner.ts index efab0b24..e20b2981 100644 --- a/packages/lint/src/file-runner.ts +++ b/packages/lint/src/file-runner.ts @@ -15,6 +15,7 @@ import * as path from 'path'; import { parse } from 'pgsql-parser'; import { lintDefinition, LintOptions } from './engine'; +import { applyIgnore } from './ignore'; import type { LintDefinitionInput, LintProblem, @@ -214,8 +215,19 @@ async function sqlFilesUnder(dir: string): Promise { return out; } +/** File-selection options layered on top of the lint options. */ +export interface FileLintOptions extends LintOptions { + /** Glob patterns to exclude (see `makeIgnoreFilter`). */ + ignore?: string[]; + /** Directory the `ignore` patterns are relative to (default `process.cwd()`). */ + cwd?: string; +} + /** Resolve a mix of file and directory paths to a sorted list of `.sql` files. */ -export async function resolveSqlFiles(paths: string[]): Promise { +export async function resolveSqlFiles( + paths: string[], + options: { ignore?: string[]; cwd?: string } = {} +): Promise { const files = new Set(); for (const p of paths) { const st = await fs.stat(p); @@ -225,12 +237,15 @@ export async function resolveSqlFiles(paths: string[]): Promise { files.add(p); } } - return [...files].sort(); + return applyIgnore([...files].sort(), options.ignore, options.cwd); } /** Lint every `.sql` file reachable from `paths` (files or directories). */ -export async function lintFiles(paths: string[], options: LintOptions = {}): Promise { - const files = await resolveSqlFiles(paths); +export async function lintFiles( + paths: string[], + options: FileLintOptions = {} +): Promise { + const files = await resolveSqlFiles(paths, { ignore: options.ignore, cwd: options.cwd }); const reports: FileReport[] = []; for (const file of files) { const source = await fs.readFile(file, 'utf8'); @@ -246,11 +261,14 @@ export function sqlTextAdapter(source: string, file?: string): SourceAdapter { } /** A {@link SourceAdapter} over `.sql` files/directories on disk. */ -export function filesAdapter(paths: string[]): SourceAdapter { +export function filesAdapter( + paths: string[], + options: { ignore?: string[]; cwd?: string } = {} +): SourceAdapter { return { id: 'files', definitions: async () => { - const files = await resolveSqlFiles(paths); + const files = await resolveSqlFiles(paths, options); const out: LintDefinitionInput[] = []; for (const file of files) { out.push(...(await sqlTextDefinitions(await fs.readFile(file, 'utf8'), file))); diff --git a/packages/lint/src/ignore.ts b/packages/lint/src/ignore.ts new file mode 100644 index 00000000..5d59e29e --- /dev/null +++ b/packages/lint/src/ignore.ts @@ -0,0 +1,103 @@ +/** + * Path exclusion for the file runner — `--ignore` / `ignore` in a config file. + * + * Most repositories that lint SQL on disk also *generate* SQL on disk + * (packaged modules, introspected schemas, codegen output), and linting + * generated output is noise. Patterns are gitignore-flavoured globs matched + * against the path relative to `cwd`: + * + * - `*` matches within a segment, `**` across segments, `?` one character. + * - A pattern with no glob character matches that path *and everything under + * it*, so `sql/` and `sql` both exclude `sql/app--1.0.0.sql`. + * - An unanchored pattern (no leading `/`) also matches at any segment + * boundary, so `generated/`, `**` patterns, and `*.gen.sql` all work. + * - A leading `/` anchors the pattern to `cwd`. + */ + +import * as path from 'path'; + +/** Turn a glob into a `RegExp` source anchored at both ends. */ +function globToRegExpSource(glob: string): string { + let out = ''; + for (let i = 0; i < glob.length; i++) { + const ch = glob[i]; + if (ch === '*') { + if (glob[i + 1] === '*') { + // `**/` may match zero segments, so `**/gen/**` also matches `gen/x`. + if (glob[i + 2] === '/') { + out += '(?:.*/)?'; + i += 2; + } else { + out += '.*'; + i += 1; + } + } else { + out += '[^/]*'; + } + continue; + } + if (ch === '?') { + out += '[^/]'; + continue; + } + out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + return out; +} + +interface CompiledPattern { + re: RegExp; + /** Anchored patterns only match from the root of the relative path. */ + anchored: boolean; +} + +function compile(pattern: string): CompiledPattern | null { + let p = pattern.trim().replace(/\\/g, '/'); + if (!p || p.startsWith('#')) return null; + const anchored = p.startsWith('/'); + if (anchored) p = p.slice(1); + p = p.replace(/^\.\//, ''); + const dirOnly = p.endsWith('/'); + if (dirOnly) p = p.replace(/\/+$/, ''); + if (!p) return null; + // A plain path (or an explicit directory) excludes the subtree under it. + const source = /[*?]/.test(p) && !dirOnly + ? globToRegExpSource(p) + : `${globToRegExpSource(p)}(?:/.*)?`; + return { re: new RegExp(`^${source}$`), anchored }; +} + +/** + * Build a predicate that answers "is this file ignored?". Paths may be + * absolute or relative; both are compared as `cwd`-relative POSIX paths. + */ +export function makeIgnoreFilter( + patterns: string[] = [], + cwd: string = process.cwd() +): (file: string) => boolean { + const compiled = patterns.map(compile).filter((c): c is CompiledPattern => c !== null); + if (compiled.length === 0) return () => false; + return (file: string): boolean => { + const rel = path.relative(cwd, path.resolve(cwd, file)).replace(/\\/g, '/'); + // Outside cwd entirely — no relative pattern can meaningfully describe it. + if (rel.startsWith('../')) return false; + const segments = rel.split('/'); + for (const { re, anchored } of compiled) { + if (re.test(rel)) return true; + if (anchored) continue; + // Unanchored: also try every suffix that starts at a segment boundary, + // so `generated/` matches `packages/x/generated/y.sql`. + for (let i = 1; i < segments.length; i++) { + if (re.test(segments.slice(i).join('/'))) return true; + } + } + return false; + }; +} + +/** Filter a list of files through {@link makeIgnoreFilter}. */ +export function applyIgnore(files: string[], patterns?: string[], cwd?: string): string[] { + if (!patterns || patterns.length === 0) return files; + const ignored = makeIgnoreFilter(patterns, cwd); + return files.filter((f) => !ignored(f)); +} diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 2311bf01..5a156d62 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -11,10 +11,15 @@ * with `createLinter({ rules, severity })` and pass your own rules in. */ +export type { ChangedFilesResult } from './changed'; +export { changedFiles, changedSqlFiles, resolveChangedBase } from './changed'; +export type { LintConfigFile, LoadedLintConfig } from './config'; +export { CONFIG_FILENAMES, configDir, findConfigFile, loadLintConfig } from './config'; export type { LintOptions } from './engine'; export { lintDefinition, severityOf } from './engine'; export { FileFinding, + FileLintOptions, FileReport, filesAdapter, lintFiles, @@ -24,6 +29,7 @@ export { sqlTextAdapter, sqlTextDefinitions } from './file-runner'; +export { applyIgnore, makeIgnoreFilter } from './ignore'; export type { CallOptions, Linter, LinterConfig } from './linter'; export { createLinter } from './linter'; export { parseUnit } from './parse-unit'; diff --git a/packages/lint/src/linter.ts b/packages/lint/src/linter.ts index 7f53728a..f36212b2 100644 --- a/packages/lint/src/linter.ts +++ b/packages/lint/src/linter.ts @@ -30,6 +30,10 @@ export interface LinterConfig { severity?: SeverityMap; /** Suppression directive keyword(s). Defaults to `['pgsql-lint', 'safegres']`. */ keyword?: string | string[]; + /** Glob patterns excluded by the file entry points (`lintFiles`). */ + ignore?: string[]; + /** Directory the `ignore` patterns are relative to (default `process.cwd()`). */ + cwd?: string; } /** Narrow a single call to a subset of the linter's rules. */ @@ -70,6 +74,7 @@ export function createLinter(config: LinterConfig = {}): Linter { const rules = config.rules ?? LINT_RULES; const severity = config.severity ?? {}; const keyword = config.keyword ?? DEFAULT_KEYWORDS; + const { ignore, cwd } = config; const bind = (options: CallOptions = {}): LintOptions => ({ ruleSet: rules, severity, @@ -83,7 +88,7 @@ export function createLinter(config: LinterConfig = {}): Linter { lintDefinition: (text, language, name, options) => lintDefinition(text, language, name, bind(options)), lintSqlText: (source, options) => lintSqlText(source, bind(options)), - lintFiles: (paths, options) => lintFiles(paths, bind(options)), + lintFiles: (paths, options) => lintFiles(paths, { ...bind(options), ignore, cwd }), lintSource: (adapter, options) => lintSource(adapter, bind(options)) }; }