Static check that the update script covers this cycle's install-script changes - #92
Static check that the update script covers this cycle's install-script changes#92jnasbyupgrade wants to merge 7 commits into
Conversation
INCOMPLETE PROTOTYPE opened for comparison against Postgres-Extensions#90, not a merge candidate. Nothing is wired into the Makefile or CI. Instead of parsing install scripts into semantic object keys, split both sides into top-level statements, normalize (strip comments, collapse whitespace), multiset-diff them, and require every added statement to appear as a substring of the normalized update script. Update scripts are overwhelmingly copy-paste from the install script, so most of the work the object model does is not needed to catch the omission. Implements one gap for real -- ALTER DEFAULT PRIVILEGES is not retroactive, so an ADP newly added in NEW leaves every same-category object that already existed in OLD needing an explicit GRANT. That flags the five enum types this tree never granted USAGE on. Enum values, new columns, changed view column lists and removals are stubbed and documented as such, and the header lists the false-positive sources. The escape hatch is a `-- update-lint: ok /REGEX/ reason` comment in the update script; unused waivers are reported so they cannot rot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
|
Claude finished @jnasbyupgrade's task in 3m 40s —— View job Review: PR #92 — text-first update-script checker (prototype)
Note: I was unable to execute This is explicitly a non-merge comparison prototype, so I focused on things the PR description/header doesn't already disclose, rather than re-listing the stubs and false positives it's upfront about. Findings1. Waiver regex parsing breaks on a literal next unless $line =~ m{--\s*update-lint:\s*ok\s+/(.+?)/\s*(\S.*?)\s*\z};The non-greedy 2. next unless $st->{norm} =~ /\A
ALTER \s+ DEFAULT \s+ PRIVILEGES \s+
IN \s+ SCHEMA \s+ (\S+) \s+
GRANT \s+ (.+?) \s+ ON \s+ TYPES \s+ TO \s+ (\S+)
/xi;This matches the exact form used today (confirmed against 3. $text =~ s/^(.*)-- SED: PRIOR TO ([^!\n]*)!/-- Not used prior to $2: $1/gm;
Smaller observations
Given the PR's own framing (prototype for comparison, not a merge candidate), none of the above blocks anything — they're notes for if/when this direction gets carried forward rather than #90's. |
bin/update_lint_textfirst now runs with no arguments against the only pair that is still editable -- the last released install script against sql/cat_tools.sql.in, with sql/cat_tools--<last released>--stable.sql.in as the update script -- deriving both version numbers so a release needs no edit here. `make update-lint` drives it from a new step in CI's lint job, alongside a step running the checker's own tests. Four changes make that pair clean and keep it meaningful: - sql.mk tags the versioned copy's @generated@ markers with "VERSIONED FILE!", and one of those markers sits inside __cat_tools.create_function()'s dollar-quoted body where no comment strip can reach it. Preprocessing now collapses both forms to the same text; left alone it made every release's copy of that function differ from the base file's. - Statements defining an object in the __cat_tools schema are exempt. That schema is dropped before the install script ends, so nothing in it can leave an updated database short an object. Calls to it are still checked. - A changed CREATE TYPE ... AS ENUM is paired with its old form and checked label by label against ALTER TYPE ... ADD VALUE. One added label is exactly the small, easy-to-miss diff this exists to catch, and it can never match by copy. - A waiver that matches nothing now fails rather than being reported and ignored: a waiver that outlives its reason is how a check like this rots. The header documents what this cannot see, led by the class that matters most: it compares statement text, so a divergence real in the catalog but invisible in the SQL -- pg_init_privs rows that ALTER DEFAULT PRIVILEGES never snapshots, as in defect D1 of Postgres-Extensions#93 -- is out of reach of any text check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @jnasbyupgrade's task in 3m 46s —— View job Review: PR #92 — text-first update-script checker (prototype, comparison against #90)
Note on testing: as in the previous review, I could not get
Given the PR's own framing (non-merge prototype for comparison with #90), these are notes for that comparison rather than blockers. 1. The PR description's numbers/"what it stubs" table looks stale relative to this revision's codeThe body says enum values are still stubbed ("install says 2. Two findings from the previous review of this PR remain unaddressed in this revision (still present at current line numbers)
Neither blocks the prototype's stated purpose (there's a documented escape hatch, and the tool is explicit that only the TYPES category is implemented), but flagging since they were raised before and the code shape is unchanged. 3. Minor, new-to-this-revision observations
What looks solid on inspection
OverallConsistent with the PR's own "honest comparison" section: the core matcher and its one real check (ADP-on-preexisting-objects) hold up under manual trace against the real tree, and enum-label handling has moved from stub to implemented since the body was written. The two carried-over findings (waiver |
A line opening with `-- update-lint` that does not parse as `-- update-lint: ok /REGEX/ reason` was dropped without a word, so a missing reason or a mistyped keyword left the author looking at a finding they believed they had already waived, with nothing pointing at the typo. Both forms now fail with the file, line number and offending text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @jnasbyupgrade's task in 3m 33s —— View job Review: PR #92 — text-first update-script checker (prototype, comparison against #90)
Note on testing: as in the previous two reviews of this PR, 1. The PR description contradicts what this revision actually does (new, not previously flagged)The body states "Nothing here is wired into the
This is a meaningful gap between the PR's own framing ("PROTOTYPE, do not merge... nothing wired into CI") and its actual effect: merging this branch as-is would land a new mandatory CI gate and a permanent 2. The numbers/"what it stubs" table is still stale (raised in the last two reviews, still unaddressed)The body still lists enum-value handling as a stub ("Needs a label-set diff") and lists 3 enum-related unmatched findings for 3. Two findings from prior reviews remain unaddressed at current line numbers
4. New minor observation:
|
…catalog Pairing a changed CREATE TYPE ... AS ENUM against ALTER TYPE ... ADD VALUE was the wrong mechanism for a case that is already covered better. The suite runs the same expected output against a fresh install, an updated database and a pg_upgraded one, so an install and an update script that disagree about an enum's labels fail one of those runs against the real catalog -- stronger than any assertion about statement text, and it needs nothing from this script. A changed enum on a type the old version already had is therefore exempt rather than waived: declaring it out of scope and then failing on it every time would have taught people to reach for a waiver. A brand-new enum type stays under the ordinary copy rule, since it copies into the update script verbatim. The limitations section now leads with the rule that decides what belongs there: ask what the right way to verify a given case is, and treat awkwardness under text comparison as evidence the case belongs to a different mechanism rather than to a cleverer parser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @jnasbyupgrade's task in 3m 9s —— View job Review: PR #92 — text-first update-script checker
Note on testing: as in every prior review of this PR, 1. The PR description is now significantly stale — it describes machinery this revision removedCommit But the PR body still describes this as one of "the three cases that needed real code" — "checked label by label," Since this PR exists specifically to be a numeric point of comparison (against #90, and for reviewers judging it), it'd be worth refreshing the body to match what's actually implemented before this is used for that purpose. This has been flagged in each of the last two reviews for a smaller version of the same gap; it's now larger since a whole feature was removed. 2. Two findings from prior reviews remain unaddressed (posted inline again at current line numbers)
3. New minor observation (posted inline)
What looks solid on inspection
OverallThe core matcher, scaffolding exemption, and ADP check all hold up under manual trace. The main actionable item is the same one raised in the last two reviews, now bigger: the PR body needs a pass to match what this revision actually does, especially since its self-comparison numbers are the point of the exercise. The two carried-over code findings and the new one are non-blocking, consistent with this being an explicitly-scoped, escape-hatch-backed tool. |
| # does not parse is an error, never a no-op (see header). | ||
| usage_error("$path:$lineno: malformed waiver, expected " | ||
| . "`-- update-lint: ok /REGEX/ reason`:\n $line") | ||
| unless $rest =~ m{\A:\s*ok\s+/(.+?)/\s*(\S.*?)\s*\z}; |
There was a problem hiding this comment.
The waiver regex has no escape mechanism for a literal / in the pattern:
unless $rest =~ m{\A:\s*ok\s+/(.+?)/\s*(\S.*?)\s*\z};(.+?) is non-greedy, so it stops at the first / after the opening delimiter. A waiver meant to match SQL text containing a / (an operator, a path literal, a character class like [a/b]) silently truncates to a shorter/different regex instead of erroring — the failure mode is a confusing "stale waiver" or an unintended match, not a parse error. Given waivers are the escape hatch for every case this tool can't handle by copy-matching, this seems worth an escape (\/) or a delimiter unlikely to appear in SQL. (Raised in prior reviews of this PR; still present at the current line.)
There was a problem hiding this comment.
Fixed in de8d51d: whitespace, not the first /, now closes the regex (/(.+?)/\s+), so a waiver pattern may contain a slash. bin/test/textfirst.t covers it — -- update-lint: ok /a/b/ division is fine waives SELECT a/b and keeps the whole reason intact.
🤖 Generated with Claude Code
| next unless $st->{norm} =~ /\A | ||
| ALTER \s+ DEFAULT \s+ PRIVILEGES \s+ | ||
| IN \s+ SCHEMA \s+ (\S+) \s+ | ||
| GRANT \s+ (.+?) \s+ ON \s+ TYPES \s+ TO \s+ (\S+) | ||
| /xi; |
There was a problem hiding this comment.
adp_gaps only recognizes one ALTER DEFAULT PRIVILEGES shape:
next unless $st->{norm} =~ /\A
ALTER \s+ DEFAULT \s+ PRIVILEGES \s+
IN \s+ SCHEMA \s+ (\S+) \s+
GRANT \s+ (.+?) \s+ ON \s+ TYPES \s+ TO \s+ (\S+)
/xi;This matches the exact form used today, but Postgres also allows ALTER DEFAULT PRIVILEGES FOR ROLE ... and omitting IN SCHEMA entirely (global defaults). Either form silently fails to match and the check does nothing — no error, no finding — for what could still be a real "objects predate this ADP" gap. Since this is the one gap category implemented "for real," worth at least a comment noting the syntax assumption so a future author extending this (e.g. to add TABLES/FUNCTIONS coverage) doesn't assume all ADP shapes are handled. (Raised in prior reviews; still present.)
There was a problem hiding this comment.
I think the right thing here is to detect use of unsupported cases and error. We don't need to boil the ocean, but we should still post lookouts for icebergs (or something...).
However, there's a bigger issue: unless I'm misunderstanding the perl regex, the regex is wrong because of the hard-coded spaces? Assuming I'm just mis-understanding I'd say it's worth a BRIEF comment on why this works.
There was a problem hiding this comment.
Both done.
Unsupported cases now error. Anything starting ALTER DEFAULT PRIVILEGES that does not match the one understood form is reported as unrecognized ALTER DEFAULT PRIVILEGES form rather than falling through the old next unless — waivable like any other finding, so it is a lookout, not a wall. New case in bin/test/textfirst.t covers it with the FOR ROLE shape.
On the regex: no hard-coded spaces. /x makes literal whitespace in the pattern insignificant, so every gap between keywords is the explicit \s+ written next to it, and those match the single spaces split_statements already collapsed the statement down to. Added as a two-line comment at the regex.
🤖 Generated with Claude Code
| my $lineno = 0; | ||
| for my $line (split /\n/, $text) { | ||
| $lineno++; | ||
| next unless $line =~ m{--\s*update-lint\b(.*)\z}; |
There was a problem hiding this comment.
parse_waivers scans $text line-by-line with a plain --\s*update-lint\b match, unlike split_statements's quote/comment/dollar-quote-aware scanner used everywhere else in this file. A -- update-lint...-looking substring inside a string literal or a dollar-quoted function body (e.g. documentation text describing this tool, quoted inside a COMMENT ON FUNCTION or similar) would be parsed as a real waiver directive and, if it doesn't match the expected grammar, would trigger a hard usage_error even though it isn't actually a comment. Narrow in practice (nothing in the current tree triggers it), but worth noting since the rest of the tool is careful about exactly this distinction.
There was a problem hiding this comment.
I think this is worth a comment as a known issue, but not a fix.
There was a problem hiding this comment.
Comment only, no fix. The LIMITATIONS bullet on line-by-line waiver scanning now names the actual sharp edge — a -- update-lint inside a string or a dollar-quoted body that does not parse is a hard error naming a line that is not a comment at all — and says a second scanner costs more than the case is worth.
🤖 Generated with Claude Code
Whitespace now closes the waiver regex rather than the first `/`, so a regex holding an operator, a character class or a path no longer truncates at it and silently waives something wider than written. Two cases are documented rather than implemented, both because the right way to verify them is not a cleverer text parser: - The ALTER DEFAULT PRIVILEGES rule recognizes only the `IN SCHEMA ... ON TYPES` shape. It reproduces a known bug shape and is not a general ADP analysis. - Waivers are read line by line rather than through the scanner, so the marker is live inside a string or a dollar-quoted body. Waivers are file-scoped, so one found in an odd place still behaves as written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @jnasbyupgrade's task in 1m 44s —— View job Review: PR #92 — text-first update-script checker
Note on testing: as in every prior review of this PR, This commit's fix is real but not complete
m{\A:\s*ok\s+/(.+?)/\s*(\S.*?)\s*\z}to m{\A:\s*ok\s+/(.+?)/\s+(\S.*?)\s*\z}i.e. requiring mandatory whitespace ( However, tracing the same backtracking logic against a pattern where the embedded
So the fix narrows the bug (a Other previously-raised itemsThe two other findings from prior reviews ( OverallThe slash-escaping fix is a solid, well-tested improvement over the previous revision and closes the common case. One narrower edge case (regex containing |
| # Run before the check below, so a bug in the checker is reported as a | ||
| # bug in the checker rather than as a finding against the SQL. | ||
| # Test::Harness is core Perl, so `prove` needs no setup. | ||
| run: prove bin/test/textfirst.t |
There was a problem hiding this comment.
uh... lets create a generic script/Makefile/whatever for running all tests in bin/test/. I don't remember if we already had that or not. Basically, because othr tests make use of stuff in bin/, we should test bin/ before anything else.
There was a problem hiding this comment.
Added make test-bin, which runs prove bin/test/ — the directory, so a new .t needs no CI edit — and made it the first step of the lint job, ahead of make lint and make update-lint. We did not already have one; CI named bin/test/textfirst.t directly.
The cross-job half of "before anything else" is not done. The heavy test job uses bin/test_existing and bin/structural_diff but only needs: [changes], so it still starts in parallel with lint. Making that ordering real means either a new job everything needs: (which also means editing all-checks-passed, since it hard-fails when the workflow's job-key set differs from its needs:) or adding lint to test's needs: and serializing the whole matrix behind the linter. Raising that separately rather than picking one here.
🤖 Generated with Claude Code
| @@ -0,0 +1,541 @@ | |||
| #!/usr/bin/env perl | |||
| # | |||
| # update_lint_textfirst - catch an install-script change whose update script | |||
There was a problem hiding this comment.
Not a fan of the name... how'd we end up with it?
There was a problem hiding this comment.
How we got it: bin/update_lint was the semantic-parser version in #90. This one arrived alongside it as the text-first alternative, and the suffix is what told the two apart while both existed. #90 is closed and that file never reached master, so the suffix no longer distinguishes anything, and make update-lint is already the target name.
Leaving the actual rename to you rather than guessing at a name.
🤖 Generated with Claude Code
| # | ||
| # 1. Split both install scripts into top-level statements (quote-, comment- | ||
| # and dollar-quote-aware, so a `;` inside any of those is not a boundary). | ||
| # 2. Normalize each statement: drop comments, collapse whitespace. |
There was a problem hiding this comment.
thought we decided NOT to drop comments? I don't see any value to it, esp. since many of these commands should literally be identical.
There was a problem hiding this comment.
This and your note on normalized_blob (dedent instead of collapsing whitespace) are the same question — dropping comments and collapsing whitespace are one normalization step, and keeping comments only works if the whitespace handling changes with it. Not changing either piecemeal; raising the normalization as a whole separately rather than guessing which half you want.
🤖 Generated with Claude Code
| # --------------------------------------------------------------------------- | ||
|
|
||
| # sql.mk resolves the @generated@ and `-- SED:` markers on the way to the | ||
| # generated .sql; doing the identical substitutions here is what makes them |
There was a problem hiding this comment.
Uh... not sure this is the right call. If we have a SED actually in effect in the base script, I'd be inclined to think it should ALSO be in effect in the relevant update scripts, which means there should be a make rule for processing them as well. If that's the case, we really shouldn't be duping that code here unless there's a really good reason to.
Also, given that those are not checked in (right?), I'd say we should actually be testing the base files. HOWEVER, given that the processed files are what actually run on the server, we should ALSO be testing those as well.
There was a problem hiding this comment.
Two facts worth having before this gets decided, then I am leaving the decision to you:
- There already is a make rule processing update scripts. sql.mk's
sql/%.sql: sql/%.sql.inpattern rule matches them, andupgrade_scripts_outputs the results intoDATA, so the SEDs are in effect there today —sql/cat_tools--0.2.3--0.3.0.sql.incarries one-- SED:marker and it does get resolved. - Correct, the
.sqlfiles are not checked in. The checker reads the.sql.insources, which is what keeps it build-free and database-free — it runs in thelintjob, not the pgxn-tools container. Also checking the processed files means depending on a build, and there is no single processed output to check: the version SEDs areMAJORVER-dependent, so the.sqldiffers per PostgreSQL major.
Raising the "test both" question separately rather than acting on it, together with your VERSIONED FILE! comment, which points the same direction.
🤖 Generated with Claude Code
| # PostgreSQL below the support floor, so the REQUIRES branch is the one that | ||
| # installs everywhere. | ||
| # | ||
| # The " VERSIONED FILE!" tag sql.mk appends when it copies the base file to a |
There was a problem hiding this comment.
This is a case where some higher-level thinking comes into play. The ONLY reason for those tags is to try and prevent someone from mistakenly looking at a generated file (because that's almost guaranteed to be a mistake). To achieve that, it's good if the tags appear every screen/page or so. BUT, if there's only ONE place where it's showing up in actual SQL then we really need to ask whether it's worth extra code to handle that. I lean towards the answer to that being "no".
There was a problem hiding this comment.
Two readings of this and I do not want to guess which you mean:
- Drop the
(?:\s+VERSIONED\s+FILE!)?handling here. That one@generated@inside__cat_tools.create_function()'s dollar-quoted body then differs between the base file and every versioned copy, so the cost is a false positive and a permanent waiver rather than saved code. - Drop the tagging itself from sql.mk's copy rule, so nothing needs handling on either side. That is the one that actually removes code, but it is a change to what the generated files say, not to this checker.
Raising it with your preprocessing-duplication comment rather than picking one.
🤖 Generated with Claude Code
|
|
||
| # The haystack for rule 4: the whole update file as one normalized string, so a | ||
| # statement copied into a DO block, a format() template or an IF branch still | ||
| # matches even though it is not a top-level statement over there. |
There was a problem hiding this comment.
ISTM we could do the same thing by just removing the appropriate amount of indentation from whichever version has more indentation... and I'm guessing that means we don't have to screw with comments.
There was a problem hiding this comment.
Same question as your comment on comment-dropping at the header's step 2 — that is what dedenting would have to replace, so I am raising both together rather than reworking the core normalization on a guess.
One thing that bears on the choice: collapsing whitespace absorbs more than leading indentation. A copy in the update script is often re-wrapped across different line breaks as well as re-indented, and rule 4 matches the statement as a substring of the whole update file rather than statement-against-statement, so "remove the appropriate amount of indentation" would need a rule for how much, measured from where, and would still have to handle the line breaks not lining up.
🤖 Generated with Claude Code
| my $lineno = 0; | ||
| for my $line (split /\n/, $text) { | ||
| $lineno++; | ||
| next unless $line =~ m{--\s*update-lint\b(.*)\z}; |
There was a problem hiding this comment.
I think this is worth a comment as a known issue, but not a fix.
| next unless $st->{norm} =~ /\A | ||
| ALTER \s+ DEFAULT \s+ PRIVILEGES \s+ | ||
| IN \s+ SCHEMA \s+ (\S+) \s+ | ||
| GRANT \s+ (.+?) \s+ ON \s+ TYPES \s+ TO \s+ (\S+) | ||
| /xi; |
There was a problem hiding this comment.
I think the right thing here is to detect use of unsupported cases and error. We don't need to boil the ocean, but we should still post lookouts for icebergs (or something...).
However, there's a bigger issue: unless I'm misunderstanding the perl regex, the regex is wrong because of the hard-coded spaces? Assuming I'm just mis-understanding I'd say it's worth a BRIEF comment on why this works.
| # .sql.in first and only: a built tree also holds the .sql generated from | ||
| # it, and matching both would make this ambiguous exactly when someone has | ||
| # run make. | ||
| my @acc = glob "$sql_dir/$ext--*--$cur.sql.in"; |
There was a problem hiding this comment.
I'd say it's worth a comment (somewhere) that there can be multiple update scripts that result in the same version (which is why --*-- is needed).
There was a problem hiding this comment.
Added at current_pair: a version can be reached from several others — this tree ships both 0.2.0--0.2.2 and 0.2.1--0.2.2 — so the target version alone does not name a file, hence the --*-- glob. The comment also says why exactly one is still expected for the current cycle, and that two would mean the pair being checked is ambiguous.
🤖 Generated with Claude Code
| `.sql` directly (historical exception; see `sql/.gitignore`). | ||
| - Never hand-edit a generated `.sql` file — edit the `.sql.in` (or, for | ||
| the base file, `sql/cat_tools.sql.in`) and regenerate. | ||
| - **A change to `sql/cat_tools.sql.in` must also extend the accumulator |
There was a problem hiding this comment.
s/accumulator//. We should just refer to these as update scripts.
There was a problem hiding this comment.
Done — dropped from CLAUDE.md, from bin/update_lint_textfirst's header and comments, and from the one test description that used it. They are just update scripts now.
🤖 Generated with Claude Code
…make An ADP shape the rule was never taught is the case the rule exists for, so skipping it silently was the failure it was written to prevent. Only the one understood form stays checked; anything else is reported and can be waived. `make test-bin` runs everything in bin/test/, ahead of the targets that use bin/ -- a broken tool should not surface as a broken extension. CI calls the target rather than naming one .t, so a new test file needs no CI edit. The sql.mk preprocessing the checker reimplements is now marked DUPLICATED AT on both sides. "Accumulator" drops out of the vocabulary: these are update scripts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Claude finished @jnasbyupgrade's task in 7m 41s —— View job Reviewing PR #92
|

Add a static check that the current cycle's update script accounts for every change to the install script
bin/update_lint_textfirstsplits two versioned install scripts into statements, diffs them, and verifies the update script between them accounts for the difference. No database, no SQL parser, no build — it reads three tracked files and exits in well under a second, so it rides CI's existinglintjob as a step rather than a job.make update-lintis the same entry point a developer runs locally.Closes the "nothing fails when you forget the accumulator" half of #93.
The principle
Never build a model of what an object is. Compare statement text.
;inside any of those is not a boundary).DOblock, aformat()or anIFstill matches.That works because update scripts are overwhelmingly copy-paste from the install script. The interesting content is not the matcher — it is the short list of cases where a copy is impossible, plus the escape hatch for the rest.
Aimed at small, subtle diffs, not whole new objects. A changed function body, a changed
GRANT, a tweaked view definition. An entire new function is hard to miss in a diff of the install script between two versions, so nothing here chases that case — and not chasing it is what keeps this small.Default scope: the current cycle only
With no arguments it checks the only pair that is still editable —
sql/cat_tools--<last-released>.sql.in→sql/cat_tools.sql.in, withsql/cat_tools--<last-released>--stable.sql.inas the accumulator. Both version numbers are derived (the current one fromcat_tools.control, the previous from the single accumulator file named after it), so a release needs no edit here.Released pairs are frozen.
--versions OLD NEWruns one by hand; that is a way to validate this script, not a gate.What it catches, on the real tree
Change a function body in
sql/cat_tools.sql.inand forget the accumulator, against a copy of the actual tree:The finding names the file, the line and the statement, so "what did I forget" is answered by the output rather than by translating an object key back to a location.
What decides whether a case belongs here
The header leads its limitations with the rule, because it is what keeps this small:
Three cases come out of that rule with real code or a real exemption:
Enum labels are out of scope, because a better mechanism already covers them. The pgTAP suite runs the SAME expected output against a fresh install, an updated database and a pg_upgraded one (see
CLAUDE.md'sTEST_LOAD_SOURCE), so an install and an update script that disagree about an enum's labels fail one of those runs against the real catalog — stronger than anything a text check could assert, and it needs nothing from this script. A changedCREATE TYPE ... AS ENUMon a type the old version already had is therefore exempt rather than waived: declaring it out of scope and then failing on it every time would teach people to reach for a waiver. A brand-new enum type stays under the ordinary copy rule, since it copies into the update script verbatim like any other new object.Scaffolding. Statements defining or dropping an object in the
__cat_toolsschema are exempt. That schema is created at the top of the install script and dropped at the bottom, so nothing in it survivesCREATE EXTENSIONand no divergence there can leave an updated database short an object — the update script's private copy of a helper is free to be bound to whatever names existed when it was written. This is a naming-convention exemption, not a per-release waiver, so it cannot rot. Only definitions are exempt, never calls:SELECT __cat_tools.create_function('cat_tools.foo', ...)creates a real object and is still checked.ALTER DEFAULT PRIVILEGES. ADP is not retroactive, so copying it into the update script is not enough: every object of that category that already existed in OLD needs an explicitGRANT. Scoped to ADP statements that are newly added.--versions 0.2.1 0.2.2reproduces the real historical bug — the five enum types created in 0.2.0/0.2.1 that never gotGRANT USAGE. Narrow on purpose: only theIN SCHEMA <s> GRANT <p> ON TYPES TO <r>shape is recognized, so aFOR ROLEor schema-less global ADP raises nothing, and TABLES/FUNCTIONS/SEQUENCES would each need their own "what creates one of these" pattern. It reproduces a known bug shape; it is not a general ADP analysis, and widening it into one is exactly the growth the rule above argues against.The escape hatch
-- update-lint: ok /REGEX/ reasonin the update script, because that is the file being reviewed and the file the exception is a property of. Two rules keep a waiver from outliving its reason:
-- update-lintthat does not parse is a hard error naming file, line and text — never a silent no-op. A missing reason or a mistyped keyword used to be dropped without a word, leaving the author staring at a finding they believed they had already waived.Whitespace closes the regex, not the first
/, so a regex holding an operator, a character class or a path does not truncate at it and silently waive something wider than written.Intent: a handful per release on genuine exceptions, never one per statement.
Results
Live dev pair: clean, 0 findings. (It is currently empty — nothing has changed since 0.3.0.)
#96 (
perm-normalize) as a preview — fetched read-only, not merged or rebased onto. This is the best available sample of what a real SQL-touching PR looks like under this check:Zero false positives on its ~166 accumulator lines.
Historical pairs, as validation only — frozen, out of default scope, and deliberately not tuned for:
0.2.0→0.2.10.2.0→0.2.20.2.1→0.2.20.2.2→0.2.30.2.3→0.3.0The five ADP findings are the real historical bug, not noise. The remaining unmatched are all the documented hand-reformatting class: a
_cat_tools.columnrebuild,cat_tools.trigger__parse, the tworelation__kind/relation__relkindhelper calls rewritten into literalCREATE OR REPLACE FUNCTION, and a bootstrap stub the update script defines in its final form instead. None is a missed update.Documented rather than implemented
The header's limitations lead with the class that matters most and is genuinely out of reach:
Also documented rather than built:
DROPdoes not appear in an install script, so a removal can only be matched by inference.GRANTs, hand-rewriting a helper call as theCREATE OR REPLACE FUNCTIONit expands to, adding an overload. The escape hatch is the answer; there is no cheaper one.IF falsebranch counts as matched.Size
bin/update_lint_textfirstbin/test/textfirst.tbin/update_lintfrom the closed #90, for reference)Tests
prove bin/test/textfirst.t— 30 tests, one case per rule plus the three things only the real tree can prove: that the current development pair is clean, that the ADP rule reproduces the historical bug, and that preprocessing erases sql.mk's" VERSIONED FILE!"tag (one such marker sits inside__cat_tools.create_function()'s dollar-quoted body, where no comment strip can reach it, and left alone it makes every release's copy of that function differ from the base file's). Deliberately a handful, not a suite — a checker whose test suite dwarfs it has stopped being the cheap option.CI wiring
Two steps appended to the existing
lintjob (ubuntu-latest, no container, checkout without submodules):prove bin/test/textfirst.tfirst, so a bug in the checker is reported as a bug in the checker rather than as a finding against the SQL, thenmake update-lint.A step, not a job —
all-checks-passedhard-fails when the workflow's job-key set differs from itsneeds:.update-lintis deliberately not tied tolintas a prerequisite in either direction:lint.mk's vendored include is guarded on$(wildcard .git), solintdoes not exist as a target in a released tarball andmake lintfails loudly there. Naming it as a prerequisite would define it with no recipe and convert that loud failure into a silent pass.