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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,11 +402,13 @@ ALTER TABLE users DROP CONSTRAINT chk_email_verified;

### GitHub Actions

Use a concrete migration path here. The Action does not shell-expand globs, so `migrations/*.sql` belongs in a `run:` step instead.

Comment on lines +405 to +406
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README now says the GitHub Action does not shell-expand globs, but action.yml was updated to expand inputs.path via FILES=($INPUT_PATH) (with nullglob). Please update this sentence/example so docs match the shipped Action behavior (either document glob support, or remove glob expansion from the action).

Copilot uses AI. Check for mistakes.
```yaml
- name: Check migration safety
uses: flvmnt/pgfence@v1
with:
path: migrations/*.sql
path: migrations/add-users.sql
max-risk: medium
```

Expand All @@ -415,7 +417,7 @@ ALTER TABLE users DROP CONSTRAINT chk_email_verified;
```yaml
- name: Analyze migrations
run: |
npx pgfence analyze --output github migrations/*.sql > pgfence-report.md
npx @flvmnt/pgfence analyze --output github migrations/*.sql > pgfence-report.md
- name: Comment on PR
uses: marocchino/sticky-pull-request-comment@v2
with:
Expand Down
9 changes: 8 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ runs:
INPUT_DB_URL: ${{ inputs.db-url }}
INPUT_STATS_FILE: ${{ inputs.stats-file }}
run: |
ARGS=("$INPUT_PATH" "--ci" "--max-risk" "$INPUT_MAX_RISK")
shopt -s nullglob
FILES=($INPUT_PATH)
if [ "${#FILES[@]}" -eq 0 ]; then
echo "No files matched path: $INPUT_PATH"
exit 1
fi

ARGS=("${FILES[@]}" "--ci" "--max-risk" "$INPUT_MAX_RISK")
if [ "$INPUT_FORMAT" != "auto" ]; then
ARGS+=("--format" "$INPUT_FORMAT")
fi
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
"build": "pnpm clean && tsc",
"prepack": "pnpm build",
"test": "vitest run tests/",
"test:watch": "vitest tests/",
"lint": "eslint src/ tests/",
Expand Down
35 changes: 34 additions & 1 deletion scripts/check-public-boundaries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@ fi
PACK_JSON=$(npm pack --dry-run --json)

PACK_JSON="$PACK_JSON" node <<'NODE'
const pack = JSON.parse(process.env.PACK_JSON ?? '[]');
function parsePackJson(raw) {
const jsonStart = raw.search(/\[\s*{/s);
if (jsonStart === -1) {
throw new Error('npm pack --json did not emit a JSON payload');
}
return JSON.parse(raw.slice(jsonStart));
}

const pack = parsePackJson(process.env.PACK_JSON ?? '[]');
const files = pack.flatMap((entry) => entry.files ?? []).map((file) => file.path);
const forbidden = files.filter((file) => /^(src\/cloud\/|src\/agent\/|dist\/cloud\/|dist\/agent\/|tests\/cloud\/)/.test(file));
if (forbidden.length > 0) {
Expand All @@ -55,6 +63,31 @@ if (missing.length > 0) {
for (const file of missing) console.error(file);
process.exit(1);
}

const { execSync } = require('node:child_process');
const trackedSources = new Set(
execSync('git ls-files src', { encoding: 'utf8' })
.split('\n')
.map((line) => line.trim())
.filter((line) => line.endsWith('.ts')),
);

const distArtifacts = files.filter((file) => /^dist\/.+\.(?:js|js\.map|d\.ts|d\.ts\.map)$/.test(file));
const orphanArtifacts = distArtifacts.filter((file) => {
const sourcePath = file
.replace(/^dist\//, 'src/')
.replace(/\.d\.ts\.map$/, '.ts')
.replace(/\.d\.ts$/, '.ts')
.replace(/\.js\.map$/, '.ts')
.replace(/\.js$/, '.ts');
return !trackedSources.has(sourcePath);
});

if (orphanArtifacts.length > 0) {
console.error('ERROR: npm package contains dist artifacts without tracked source files:');
for (const file of orphanArtifacts) console.error(file);
process.exit(1);
}
NODE

echo "Public repo boundaries look good."
2 changes: 1 addition & 1 deletion src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export function applyRules(
results.push(...checkBestPractices(stmt));
results.push(...checkPreferRobustStmts(stmt));
results.push(...checkAlterEnum(stmt, config));
results.push(...checkReindex(stmt));
results.push(...checkReindex(stmt, config.minPostgresVersion));
results.push(...checkRefreshMatView(stmt));
results.push(...checkTrigger(stmt));
results.push(...checkPartition(stmt, config));
Expand Down
1 change: 1 addition & 0 deletions src/extractors/typeorm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export async function extractTypeORMSQLFromSource(
line: loc.line,
column: loc.column,
message: `TypeORM builder API detected (${upInfo.paramName}.${methodName}) -- pgfence can only analyze ${upInfo.paramName}.query() raw SQL calls`,
unanalyzable: true,
});
return;
}
Expand Down
86 changes: 47 additions & 39 deletions src/init.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { existsSync } from 'node:fs';
import { mkdir, writeFile, chmod } from 'node:fs/promises';
import { join } from 'node:path';
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { join, resolve } from 'node:path';

const execFileAsync = promisify(execFile);

const PRE_COMMIT_HOOK_CONTENT = `#!/bin/sh
# pgfence pre-commit hook
Expand All @@ -25,50 +29,54 @@ fi
`;

export async function installHooks(): Promise<void> {
const cwd = process.cwd();
const huskyPath = join(cwd, '.husky');
const gitHooksPath = join(cwd, '.git', 'hooks');
const cwd = process.cwd();
const huskyPath = join(cwd, '.husky');

let targetDir: string;
let usingHusky = false;
let targetDir: string;
let usingHusky = false;

if (existsSync(huskyPath)) {
targetDir = huskyPath;
usingHusky = true;
} else if (existsSync(join(cwd, '.git'))) {
if (!existsSync(gitHooksPath)) {
await mkdir(gitHooksPath, { recursive: true });
}
targetDir = gitHooksPath;
} else {
throw new Error('Neither .husky nor .git directory found. Are you in a git repository?');
if (existsSync(huskyPath)) {
targetDir = huskyPath;
usingHusky = true;
} else {
try {
const { stdout } = await execFileAsync('git', ['rev-parse', '--git-path', 'hooks'], { cwd });
targetDir = resolve(cwd, stdout.trim());
if (!targetDir) {
throw new Error('git did not return a hooks directory');
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Neither .husky nor a git hooks directory could be resolved. Are you in a git repository? ${message}`);
}
}

const hookFile = join(targetDir, 'pre-commit');
const hookFile = join(targetDir, 'pre-commit');

let existingContent = '';
try {
const { readFile } = await import('node:fs/promises');
existingContent = await readFile(hookFile, 'utf8');
} catch (err: unknown) {
const error = err as { code?: string };
if (error.code !== 'ENOENT') throw err;
}
let existingContent = '';
try {
existingContent = await readFile(hookFile, 'utf8');
} catch (err: unknown) {
const error = err as { code?: string };
if (error.code !== 'ENOENT') throw err;
}

if (existingContent.includes('pgfence analyze')) {
console.log(`✅ pgfence pre-commit hook is already installed in ${targetDir}`);
return;
}
if (existingContent.includes('pgfence analyze')) {
console.log(`✅ pgfence pre-commit hook is already installed in ${targetDir}`);
return;
}

const newContent = existingContent
? existingContent + '\n' + PRE_COMMIT_HOOK_CONTENT.replace('#!/bin/sh\n', '')
: PRE_COMMIT_HOOK_CONTENT;
const existingBody = existingContent.replace(/^#![^\n]*\n?/, '');
const newContent = existingBody
? `${PRE_COMMIT_HOOK_CONTENT}\n# Existing pre-commit hook preserved below\n${existingBody}`
: PRE_COMMIT_HOOK_CONTENT;

await writeFile(hookFile, newContent);
await chmod(hookFile, '755');
await mkdir(targetDir, { recursive: true });
await writeFile(hookFile, newContent);
await chmod(hookFile, '755');

console.log(`✅ pgfence pre-commit hook installed successfully in ${targetDir}`);
if (usingHusky) {
console.log('💡 Note: You are using husky. Ensure husky is installed and enabled.');
}
console.log(`✅ pgfence pre-commit hook installed successfully in ${targetDir}`);
if (usingHusky) {
console.log('💡 Note: You are using husky. Ensure husky is installed and enabled.');
}
}
51 changes: 48 additions & 3 deletions src/lsp/code-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,51 @@ function isExecutableSafeRewrite(safeRewrite: SafeRewrite): boolean {
return hasExecutableStep;
}

function statementStartInsertPos(text: string, startOffset: number): Position {
const startPos = offsetToPosition(text, startOffset);
const startChar = text.charCodeAt(startOffset);
if (startChar === 10 || startChar === 13) {
return Position.create(startPos.line + 1, 0);
}
return Position.create(startPos.line, 0);
}

function rangesEqual(
left: { start: { line: number; character: number }; end: { line: number; character: number } },
right: { start: { line: number; character: number }; end: { line: number; character: number } },
): boolean {
return (
left.start.line === right.start.line &&
left.start.character === right.start.character &&
left.end.line === right.end.line &&
left.end.character === right.end.character
);
}

function getPolicyIgnoreInsertPos(
analysis: AnalyzeTextResult,
diagnostic: CodeActionParams['context']['diagnostics'][number],
text: string,
): Position {
for (let i = 0; i < analysis.policyViolations.length; i++) {
const violation = analysis.policyViolations[i];
if (violation.ruleId !== diagnostic.code) continue;

const sourceRange = analysis.policySourceRanges[i];
if (!sourceRange) {
return Position.create(0, 0);
}

const startPos = offsetToPosition(text, sourceRange.startOffset);
const endPos = offsetToPosition(text, sourceRange.endOffset);
if (!rangesEqual({ start: startPos, end: endPos }, diagnostic.range)) continue;

return statementStartInsertPos(text, sourceRange.startOffset);
}

return Position.create(0, 0);
}

/**
* Generate code actions for diagnostics in the requested range.
*/
Expand Down Expand Up @@ -108,8 +153,7 @@ export function getCodeActions(
}

// 2. Ignore this rule for this statement
const ignoreLine = startPos.line;
const ignoreInsertPos = Position.create(ignoreLine, 0);
const ignoreInsertPos = statementStartInsertPos(text, range.startOffset);
actions.push({
title: `pgfence-ignore: ${check.ruleId}`,
kind: CodeActionKind.QuickFix,
Expand All @@ -131,6 +175,7 @@ export function getCodeActions(
if (!matchedCheck) {
for (const violation of analysis.policyViolations) {
if (violation.ruleId !== ruleId) continue;
const ignoreInsertPos = getPolicyIgnoreInsertPos(analysis, diagnostic, text);

actions.push({
title: `pgfence-ignore: ${violation.ruleId}`,
Expand All @@ -139,7 +184,7 @@ export function getCodeActions(
edit: {
changes: {
[params.textDocument.uri]: [
TextEdit.insert(Position.create(0, 0), `-- pgfence-ignore: ${violation.ruleId}\n`),
TextEdit.insert(ignoreInsertPos, `-- pgfence-ignore: ${violation.ruleId}\n`),
],
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { pathToFileURL } from 'node:url';
import type { ParsedStatement } from './parser.js';
import type { CheckResult, ExtractionWarning, PgfenceConfig, PolicyViolation } from './types.js';

const ALLOWED_PLUGIN_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts']);
const ALLOWED_PLUGIN_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);

function isWithinRoot(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
Expand Down
Loading