Skip to content

Add bin/update_lint: static check that the update script covers the object diff - #90

Open
jnasbyupgrade wants to merge 2 commits into
Postgres-Extensions:masterfrom
jnasbyupgrade:update-lint
Open

Add bin/update_lint: static check that the update script covers the object diff#90
jnasbyupgrade wants to merge 2 commits into
Postgres-Extensions:masterfrom
jnasbyupgrade:update-lint

Conversation

@jnasbyupgrade

Copy link
Copy Markdown
Contributor

A PR that changes an extension's SQL must also extend the update script from the last released version, so an install that got there via ALTER EXTENSION UPDATE reaches the same objects as a fresh install. Nothing enforced that until the runtime check ran, which needs a database, seven PostgreSQL majors and minutes of CI. bin/update_lint parses the two install scripts on either side of an update, diffs their object sets, and verifies the update script accounts for every added and removed object. It needs no database, no make and no pg_config — it reads the tracked .sql.in sources directly — so it runs in the existing cheap lint job alongside the style linter, in about a tenth of a second.

It compares object identity, never definition, so it is an early-warning net rather than an authority: bin/structural_diff remains the check that a fresh install and an updated install are actually equivalent. A clean run here means only that no object was added or removed without the update script accounting for it.

It reproduces a real defect

Run against the frozen pre-0.2.2 update scripts, it reports the five enum types that never got GRANT USAGE:

$ bin/update_lint --versions 0.2.1 0.2.2
  5 object(s) added, 0 removed

Not handled by the update script:
  added, never created:   acl:type:cat_tools.constraint_type
  added, never created:   acl:type:cat_tools.object_type
  added, never created:   acl:type:cat_tools.procedure_type
  added, never created:   acl:type:cat_tools.relation_relkind
  added, never created:   acl:type:cat_tools.relation_type

That is the same defect bin/structural_diff found at runtime in #55, fixed forward in #68. Both pairs are pinned in the suite as a known-bad oracle: the files are frozen, so the finding is stable and documents the history.

Catching it statically depends on modelling ALTER DEFAULT PRIVILEGES as persistent pg_default_acl state seeded from the old install script, since the old database already ran that statement. Without the seeding, 0.2.30.3.0 false-positives on every new type; with per-file-only flags, the historical gap is invisible.

Scope

Only the update path into the current unreleased version is linted by default. Released version files are frozen once tagged, so a finding against a historical pair could never be fixed and would be permanently red — the same reasoning that already scopes LINT_TARGETS away from them. There is deliberately no baseline or suppression file: the exclusion is structural, and historical pairs stay reachable by passing an explicit pair. The linted pair is derived from default_version and the highest tracked release rather than hardcoded, so it follows the release cycle with no edit. A missing update script is treated as empty rather than skipped, since skipping would pass silently on exactly the omission this check exists to catch.

Parsing

Most of this extension's objects never appear in a CREATE statement — sql/cat_tools.sql.in builds 66 functions through __cat_tools.create_function() against 4 literal CREATE FUNCTIONs — so a CREATE-scanning lint would see a fraction of the extension and report all-clear. A quote- and comment-aware scanner splits statements, a dispatch table classifies each one, and dynamic SQL is followed through three gateways: create_function(), __cat_tools.exec(), and DO blocks. That distinction is load-bearing in both directions. Four views and a role exist only inside dollar quotes reached through a gateway, while create_function's own body contains format() templates (CREATE OR REPLACE FUNCTION %s() that must not become objects.

Any statement the dispatch table does not recognize is a hard error rather than a silent skip, which is what keeps a green run meaningful. All 20 SQL sources in the tree parse with no unrecognized statement, so this lands with no suppression backlog.

Verification

Beyond the 145-test suite, the extracted object set was diffed against the real catalog after installing the extension into PostgreSQL 17: 192/192 exact match across schemas, relations, indexes, types, enum labels, functions, aggregates, casts and constraints, and no blind spots on ACLs or comments.

make update-lint runs the check and make update-lint-test runs the suite; both are wired into the existing lint job. The target stays unwired from lint in both directions, deliberately: lint.mk's vendored include is guarded on $(wildcard .git), so lint does not exist as a target in a released tarball and make lint fails loudly there. Naming it as a prerequisite would define it with no recipe and turn that loud failure into a silent pass.

Known limitations

Documented in the script header, and the reason it is a net rather than an authority:

  • Functions are keyed by name and arity, not argument types, so overloads differing only in a type collide — live here for cat_tools.relation__kind and eight other names.
  • The ACL, comment, security-label and role-grant kinds are direction-blind, so a REVOKE-only update script satisfies coverage for an added grant. That is the same class as the five-grant finding above.
  • ALTER DEFAULT PRIVILEGES seeding assumes the same role runs the update as ran the install, since pg_default_acl is per-role.
  • Version-conditional -- SED: markers resolve to the newest-PG branch, so an object existing only on a pre-floor major is not tracked.

…bject diff

A PR that changes an extension's SQL must also extend the update script from
the last released version, so an install that got there via ALTER EXTENSION
UPDATE reaches the same objects as a fresh install. Nothing enforced that
until the runtime check ran, which needs a database, seven PostgreSQL majors
and minutes of CI. bin/update_lint parses the two install scripts on either
side of an update, diffs their object sets, and verifies the update script
accounts for every added and removed object. It needs no database, no make
and no pg_config -- it reads the tracked .sql.in sources directly -- so it
runs in the existing cheap lint job alongside the style linter.

It compares object IDENTITY, never definition, so it is an early-warning net
rather than an authority; bin/structural_diff remains the check that a fresh
install and an updated install are actually equivalent. Run against the
frozen pre-0.2.2 update scripts it reports the five enum types that never
got GRANT USAGE, the same defect structural_diff found at runtime, which is
pinned in the test suite as a known-bad oracle.

Only the update path into the current unreleased version is linted by
default. Released version files are frozen once tagged, so a finding against
a historical pair could never be fixed and would be permanently red; there
is deliberately no baseline or suppression file, because the exclusion is
structural. Historical pairs stay reachable by passing an explicit pair.

The Makefile target stays unwired from `lint` in both directions: lint.mk's
vendored include is guarded on $(wildcard .git), so `lint` does not exist as
a target in a released tarball and `make lint` fails loudly there. Naming it
as a prerequisite would define it with no recipe and turn that loud failure
into a silent pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: b2455dbc-addd-4618-85ee-d18e81f03d4d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @jnasbyupgrade's task in 2m 32s —— View job


Claude is reviewing this PR

Todo list

  • Gather context (read CLAUDE.md, PR diff, changed files)
  • Review supporting files (Makefile, ci.yml, .gitattributes, version-derivation & vercmp logic, test suite, fixtures)
  • Deep review of bin/update_lint parser/dispatch/ACL-seeding logic (in progress)
  • Post final review feedback

CLAUDE.md had no mention of `make update-lint`/`bin/update_lint`, so the
only way to discover it was a red CI check; add a short entry next to the
SQL file conventions it enforces. Also add a `hint:` line to the linter's
gap-found failure path so a first-time reader knows what to do next
(extend the named update script) instead of just that something is wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Claude finished @jnasbyupgrade's task in 8m 10s —— View job


I'll analyze this and get back to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant