pgfence 0.4.1: Trust Contract Hardening, 22 New Tests

We audited every rule, extractor, and reporter in pgfence and fixed 18 silent failure paths, 6 bugs, and 11 stale comments. Here is what we found and what we fixed.

pgfence’s Trust Contract says the fatal failure mode is not false positives, it is false negatives: silently passing dangerous migrations. We took that seriously and ran a comprehensive audit of the entire codebase: every rule, every extractor, every reporter, every policy check.

Here is what we found and what we fixed.

The biggest problem: transpiler silent failures

The Knex and Sequelize transpilers convert ORM-specific schema builder calls (knex.schema.createTable, queryInterface.addColumn, etc.) into SQL that pgfence can analyze. When those transpilers encountered unexpected arguments, like a destructured callback parameter, a variable reference instead of a string literal, or fewer arguments than expected, they returned empty results with zero warnings.

That means a migration statement like knex.schema.dropTable(tableNameVar) would simply vanish from the analysis. pgfence would report 100% coverage, show no warnings, and CI would pass. The migration would deploy.

We found 18 silent failure paths across the two transpilers (6 in Knex, 12 in Sequelize). Every one now emits an ExtractionWarning explaining what could not be transpiled and why manual review is required.

Coverage calculation was lying

The reporters in the audited set calculated coverage percentage using the total count of extraction warnings as a proxy for “unanalyzable statements.” But extraction warnings include informational messages too: TypeORM builder API detection, conditional SQL advisories, “no up() method found” notices. A file with 5 fully analyzed statements and 3 informational warnings would report 40% coverage.

We added an unanalyzable flag to ExtractionWarning that distinguishes truly unanalyzable statements (dynamic SQL, transpile failures, parse errors) from informational messages. Coverage percentage is now accurate.

Plugin crashes were invisible in CI

When a custom plugin rule threw an exception, the error was written to stderr but not included in the structured analysis output (JSON, GitHub PR comment, SARIF). In CI, stderr often scrolls past. The migration would pass with a green checkmark while the custom safety rule was silently broken.

Plugin errors are now surfaced as ExtractionWarning entries in all output formats.

Six bugs fixed

  • Trace mode DB connection leak: if tracing failed mid-run, the Postgres connections were never closed, potentially hanging the Docker container
  • Policy ignore bleed: a -- pgfence-ignore comment on any statement would suppress file-level policy checks (like missing-lock-timeout) for the entire file. Now only the first statement’s ignores apply to file-level checks.
  • lock-timeout-after-dangerous-statement was not suppressible: inconsistent with all other policy violations, this rule could not be silenced via inline ignore
  • Stale adjustedRisk in trace mismatch: when trace mode detected a lock mismatch, the risk level from DB-size adjustment leaked through instead of the recalculated value
  • NaN on timeout CLI options: --max-lock-timeout=foo silently disabled the threshold check instead of erroring
  • Poor error context: stats file parse errors and package.json read errors now include the file path and actual error message

LSP improvements

  • Format auto-detection failure now tells you what went wrong instead of silently falling back to raw SQL
  • If analysis crashes, stale diagnostics from the previous run are cleared (previously, “safe” diagnostics would persist after a crash)
  • Configuration fetch errors from minimal LSP clients (Neovim, Helix) are now logged instead of silently swallowed

22 new tests (371 to 393)

  • 10 SARIF reporter tests: the SARIF reporter had zero test coverage. Now covers structure validation, severity mapping, coverage summary, safe rewrite inclusion, SAFE-check filtering, and artifact locations.
  • DROP SCHEMA / DROP SCHEMA CASCADE / DROP CONSTRAINT: three high-severity operations that had no test coverage
  • 6 adjustRisk boundary tests: exact thresholds at 9,999 vs 10,000, 999,999 vs 1,000,000, and 9,999,999 vs 10,000,000 rows
  • REFRESH MATERIALIZED VIEW WITH NO DATA: verifies MEDIUM risk (not HIGH) for the skipData branch
  • 2 coverage calculation tests: verify informational warnings do not deflate coverage percentage

11 stale comments fixed

We verified every lock-mode comment in the codebase against PostgreSQL’s documentation and source code. All lock modes are correct, but several file headers and JSDoc comments were stale after previous fixes. REINDEX TABLE (SHARE, not ACCESS EXCLUSIVE), ATTACH PARTITION (SHARE UPDATE EXCLUSIVE on PG12+), ALTER TYPE ADD VALUE (type object lock, not table lock), and 8 others.

Upgrade

npm install @flvmnt/pgfence@0.4.1

No breaking changes. All fixes are backward-compatible. The unanalyzable field on ExtractionWarning is optional, so existing plugins continue to work unchanged.

Full changelog: CHANGELOG.md

← All posts