Repository navigation
feat: initial pgsql-coding plugin - #1
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first eval run showed the migrations recipe set lock_timeout = '1s' in the CREATE INDEX CONCURRENTLY file. Tested on PostgreSQL 18.6: with any older transaction open, the build fails with a lock timeout and leaves an INVALID index, while a waiting concurrent build does not block plain DML. The recipe now sets lock_timeout = 0 there; the router, Cursor rule and reviewer say the same, and the eval rubric no longer requires a timeout on that file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sebastian-iancu
left a comment
There was a problem hiding this comment.
Review
The eight skills and both agents are ready. Descriptions name real asks and point at the sibling that owns the neighbouring job, no rule is copied in full across two SKILL.md files, and the router, the migrations skill, lock-levels.md, patterns.md and migration-tools.md agree that CREATE INDEX CONCURRENTLY must not use a short lock_timeout. Both agents match the house shape used by go-reviewer: model: inherit, a description that says when to invoke and when not to, a "When to invoke" section, a fixed output format, and a Bash grant the body treats as a contract. pgsql-reviewer is blue and pgsql-plan-analyst is cyan. The plan analyst loads pgsql-performance and runs plan-summary.py instead of restating its thresholds.
The summariser, which that agent is told to trust, mis-states several findings and crashes on a legal plan. The migration timeout rule has already drifted in the reviewer checklist and the eval grader.
Important
skills/pgsql-performance/scripts/plan-summary.py— silent wrong summaries and one crash. Inline comment onmisestimate.:172clamps the smaller row count up to 1, so a fractional estimate (an index probe at0.01that returns0.50, 10,000 times) never reaches the tenfold test.:189reports aLIMITearly stop as the node where an overestimate starts.explain.mdsays that stop is not an estimation error.:205formatsn.totalfor a node withloops >= 1000.EXPLAIN (ANALYZE, TIMING OFF)omits per-node time, and the handler at:235does not catchTypeError, so the script prints a partial summary and then crashes.explain.mdtells the reader to useTIMING OFFwhen only row counts matter.:207readsRows Removed by Filterand ignoresRows Removed by Join Filter, which both parsers store.:212multipliesHeap Fetchesbyloops. That counter is already a total.Rows Removed by Filteris a per-loop average, so the multiply is right only for the filter.:125builds a JSON title fromNode Typealone, so TEXTHash Anti Joinand JSONHash Joinare different names for the same node. Incremental-sort and tuplestore spills (Average Disk,Storage: Disk) are not read.
agents/pgsql-reviewer.md:64restates the migration rule and dropsSHAREandSET lock_timeout = 0. Inline comment.evals/migrations-tenant-column/graders/safe-sequence.md:8still passes the bug the last commit fixes: a concurrent-index file withSET lock_timeout = '1s'is allowed when some other file sets a timeout. Inline comment.scripts/validate.py:326returns success when either hook config is missing, and:313accepts a Cursor command that contains${CLAUDE_PLUGIN_ROOT}because the regex only captureshooks/<script>.sh.:165measures a foldeddescription:as the single token>-. The shipped tree is valid, so CI is green. The checks do not guard the shapesdocs/testing.mdnames.
Suggestions
skills/pgsql-migrations/SKILL.md:18setslock_timeout = 0and does not mentionstatement_timeout.lock-levels.md:50also clearsstatement_timeout, "or a generous bound". A file that only follows the skill can keepstatement_timeout = '5s'from the strong-lock template and abort a long build. Use one sentence: a concurrent index, drop or reindex file sets both to0.pgsql-jsonbandpgsql-queryboth state that an aggregate over zero rows returns NULL. Keep thejsonb_agg/ORDER BYprescription in jsonb and point the null-over-no-rows fact atpgsql-query.README.md:69,docs/install.md:72andAGENTS.md:59say the skill nudge reads the added text on both hosts.hooks/skill-nudge.sh:26-28does that only when the payload hastool_input. Cursor'safterFileEditreads the whole file.- The plan-summary tests lock the TEXT findings on
join-spilland never assert exclusive time, the JSON twin ofsubplan-loops(JIT under 10% must not printJIT took), heap fetches, a join filter, orTIMING OFF.hooks-test.shnever checkssession-start's exit code. pgsql-queryandpgsql-performancedescriptions are at 988 and 998 characters of the 1,024 limit.
Strengths
Sibling boundaries are real: index choice versus a live build, plan reading versus index design, squawk setup versus a squawk finding. Hook scripts exit 0, quote paths, and do not expand ${CLAUDE_PLUGIN_ROOT} on the Cursor side. The lock_timeout = '1s' recipe is gone from every CREATE INDEX CONCURRENTLY example. validate.py passes on the tree that shipped, including reference links, hook parity and the doc inventory.
Fix the summariser's row math and the crash, and make the reviewer checklist and the migrations grader require lock_timeout = 0 on a concurrent index file. The skills and the agent structure do not need a change.
| if not n.analyzed or n.never: | ||
| return None | ||
| est, rows, loops = n.est or 0.0, n.rows or 0.0, n.loops or 1 | ||
| hi, lo = max(est, rows), max(min(est, rows), 1.0) |
There was a problem hiding this comment.
lo = max(min(est, rows), 1.0) makes the ratio hi / 1. Any node whose larger per-loop row count is under 10 can never reach the tenfold test, and a fractional estimate disappears: an index probe estimated at 0.01 rows that returns 0.50, run 10,000 times (50×, 5,000 rows), produces no finding. The hi * loops >= 1000 guard never runs.
Divide by the real smaller count. If that count is 0, treat the ratio as large. Keep hi * loops >= 1000 as the only noise filter.
Same function, nearby:
:189treats aLIMITearly stop as the node where an overestimate starts.explain.mdsays that stop is not an estimation error. Skipoverwhen an ancestor isLimit.:205formatsn.totalwheneverloops >= 1000.EXPLAIN (ANALYZE, TIMING OFF)omits per-node time,work_mstreats that as 0, and:235does not catchTypeError, so the script prints a partial ranking and crashes.explain.mdtells the reader to useTIMING OFFwhen only row counts matter. Skip the exclusive-time ranking when timing was not collected, and omit the per-loop time in the loops finding when it is missing.:207ignoresRows Removed by Join Filter, which both parsers store (:103,:135). Apply the same threshold.:212multipliesHeap Fetchesbyloops. That counter is already a total.Rows Removed by Filteris a per-loop average, so the multiply belongs only on the filter.:125titles a JSON node fromNode Typealone, so TEXTHash Anti Joinand JSONHash Joindiffer. InsertJoin Typewhen it is notInner, and foldPartial ModeplusStrategyintoAggregate.
There was a problem hiding this comment.
Fixed in 7894023. Each point is checked against a plan captured on 18.6 (scripts/testdata/plans/), and each new test fails on a5079a7's script.
- Ratio. It now divides by the real smaller count, and a node that returned nothing counts as one row over all its loops. One exception, based on evidence: an estimate of exactly 1 is not called an overestimate.
clamp_row_estforces every estimate to at least one row, so a fractional estimate such as0.01never appears in a plan. Dividing by a fractional actual below that floor moves the origin off the node that is wrong: inclamp-probe.txtthe probe is estimated at 1 and returns 0.04, while the error is the Nested Loop's (estimated 20,000, got 750). An estimate above 1 that returns a fraction is now reported (fractional-probe.txt: estimated 8, got 0.38, ×21). The old code missed it. - LIMIT.
overis skipped below aLimit, but the early stop ends at a node that reads all its input first (Sort,Hash, a plain or hashed aggregate). "AnyLimitancestor" would hide a real error: inlimit-sort.txt,LIMIToverSortoverSeq Scanstill runs the scan to the end, and its ×5,103 overestimate is real. The same rule covers the inner side of a nested-loop semi or anti join, which stops at the first match. The old code reported ×30,000 on a plainEXISTS(semi-stop.txt).explain.mdnow says so. - TIMING OFF. There is no ranking by time, and the loops finding leaves out the time. Both formats crashed before.
- Join filter. It is now reported. The rows it passed are the rows kept plus the rows the node's own
Filterremoved afterwards, because the join filter runs first. - Heap Fetches. It is no longer multiplied.
explain.cprintsntuples2as a raw total, while bothRows Removedcounters arenfiltered / nloops. - Names and spills.
Join Type,StrategyandPartial Modeare folded into the JSON name, and every TEXT/JSON fixture pair is tested for identical node names. The script now also reads incremental-sort group spills and PG 18 tuplestoreStorage: Disk. A HashAggregate spill was read from TEXT but not from JSON (HashAgg Batches); that's fixed too.
Mutation checks: removing the one-row floor, the stop at a Sort, or the semi-join rule each fails its own test.
| - **Indexing** (`pgsql-indexing`): an unindexed foreign key; a range column before the equality columns; prefix `LIKE` without `text_pattern_ops` or `"C"`; a partial or expression index the query cannot match (bind parameter, different expression, non-IMMUTABLE cast); an index that blocks HOT updates; a duplicate index; a drop on `idx_scan = 0` without checking the reset time, replicas and uniqueness. | ||
| - **Performance evidence** (`pgsql-performance`): a fix with no plan behind it; `enable_*` as a fix; a query per row in a loop (N+1, `pgsql-query`); `SELECT *` over large jsonb or text (`pgsql-jsonb`); an exact `COUNT(*)` for a UI total; a CTE used twice that blocks an outer filter; timeouts in `postgresql.conf`; `auto_explain.log_analyze` in production unsampled. | ||
| - **Schema and security** (`pgsql-schema`): `serial`; an identity column with no primary key; `money`, `float` or `timestamp` misused; a `varchar(n)` limit with no rule; an enum whose values will be retired; a missing `NOT NULL`; a falsely `IMMUTABLE` function; `SECURITY DEFINER` without `SET search_path` and a `REVOKE` from `PUBLIC`; row-level security tested as the owner; a view over a protected table without `security_invoker`. | ||
| - **Migrations** (`pgsql-migrations`; name the squawk rule): no `lock_timeout` before DDL that takes `ACCESS EXCLUSIVE` or `SHARE ROW EXCLUSIVE`, or a short one before a concurrent index build (it aborts the build and leaves an INVALID index); an index built or dropped without `CONCURRENTLY`, or `CONCURRENTLY` inside a transaction (goose without `NO TRANSACTION`); a concurrent build retried without dropping an INVALID leftover; a constraint without `NOT VALID`, or `NOT VALID` and `VALIDATE` in one file; a bare `SET NOT NULL` on a populated column; a rewriting type change; a rename or drop shipped with the code change; a one-statement backfill. |
There was a problem hiding this comment.
This checklist restates pgsql-migrations and has already drifted. The skill (SKILL.md:14 and :18) requires a timeout before ACCESS EXCLUSIVE, SHARE ROW EXCLUSIVE or SHARE, and requires lock_timeout = 0 on the concurrent-index file. This line omits SHARE and treats "not a short timeout" as enough.
lock-levels.md:50 also clears statement_timeout, because a SET from the previous file stays on the session. Name SHARE, and require SET lock_timeout = 0 and SET statement_timeout = 0 on a concurrent index, drop or reindex file. The plan analyst does not have this problem: it points at explain.md instead of copying the thresholds.
There was a problem hiding this comment.
Fixed in a0cd09e. The line now names SHARE and flags a concurrent index build, drop or reindex whose file does not set lock_timeout = 0 and statement_timeout = 0. The same rule now reads identically in pgsql-migrations (ad306fd), in lock-levels.md ("or a generous bound" is dropped), and in patterns.md, the router checklist and the Cursor rule (83eaa20).
|
|
||
| PASS if all of these hold: | ||
| - The answer says the teammate's migration is unsafe and names at least two concrete reasons, such as: goose runs the whole file in one transaction, so the `ACCESS EXCLUSIVE` lock from `ADD COLUMN` is held through the full-table `UPDATE`; `SET NOT NULL` scans the table under `ACCESS EXCLUSIVE`; the foreign key validates every row while blocking writes; the plain `CREATE INDEX` blocks writes for the whole build. | ||
| - Every rewritten migration file whose DDL takes `ACCESS EXCLUSIVE` or `SHARE ROW EXCLUSIVE` (adding the column, adding the foreign key or not-null constraint) sets `lock_timeout` before its first DDL statement. A file holding only concurrent index statements is not required to set it; setting `lock_timeout = 0` there, or saying that a short `lock_timeout` would abort the concurrent build, is correct. |
There was a problem hiding this comment.
A file of only concurrent index statements "is not required to set" lock_timeout, and the FAIL line at :15 only fails when the rewrite sets no lock_timeout anywhere. SET lock_timeout = '1s' directly above CREATE INDEX CONCURRENTLY still passes, which is the bug a5079a7 fixes.
FAIL when a concurrent index file sets a non-zero lock_timeout or statement_timeout. Treat an explicit 0 as required.
There was a problem hiding this comment.
Fixed in 9be360b. To pass, the file with the concurrent index statements must now set lock_timeout = 0 and statement_timeout = 0. A file with CREATE INDEX CONCURRENTLY or DROP INDEX CONCURRENTLY fails if either value is non-zero or unset.
I re-ran the case: 3 runs per arm, $1.87. With the plugin it scores 1.00 (3 of 3 pass). Without it, 0.00 (3 of 3 fail). None of the three no-plugin answers sets either timeout before CREATE INDEX CONCURRENTLY. Before this change, the no-plugin arm scored 0.67, so the case now tells the two arms apart on exactly this rule.
Both manifests' repository field, the issue-template README link and the --plugin-dir examples in AGENTS.md, README.md, docs/install.md and docs/versioning.md named Cadasto/pgsql-coding, which only resolved through GitHub's rename redirect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CONCURRENTLY file sets statement_timeout = 0 as well as lock_timeout = 0: a timeout set by an earlier migration file stays on the session and would abort the long build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lters plan-summary.py: - does not report a misestimate on a node that stopped early: below a Limit, or on the inner side of a nested-loop semi or anti join, unless a Sort, Hash or hashed aggregate in between read all its input first; - reports spills from incremental sort groups, HashAggregate batches and PG 18 tuplestores (Material, WindowAgg, CTE Scan), and filters that discard most rows through a join filter; - names JSON nodes as TEXT does (aggregate strategy, partial mode, join type), and says when a plan ran with TIMING OFF. explain.md gains the semi/anti-join early stop, the per-loop and total counters (checked on 18.6, cited to explain.c) and the new spill lines. plan-summary-test.py covers each case against real PostgreSQL 18 plans added under scripts/testdata/plans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lock-levels.md drops "or a generous bound", patterns.md names statement_timeout beside lock_timeout, and the router and Cursor rule checklists say a concurrent build runs in a file that sets both to 0, matching pgsql-migrations and the migration-tools.md example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The checklist named only ACCESS EXCLUSIVE and SHARE ROW EXCLUSIVE and accepted any concurrent build without a short lock_timeout. It now names SHARE and flags a concurrent index build, drop or reindex whose file does not set lock_timeout = 0 and statement_timeout = 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ENTLY SET lock_timeout = '1s' above CREATE INDEX CONCURRENTLY passed whenever another file set a timeout. The concurrent file must now set lock_timeout = 0 and statement_timeout = 0, and a non-zero or missing value fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…olded descriptions
A missing hooks.json or cursor-hooks.json passed silently; it is now an error. Claude Code commands must reach their script through ${CLAUDE_PLUGIN_ROOT} and Cursor commands must not use it. A folded or literal description block is measured whole instead of as '>-'. Each check has a --selftest case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cursor sends edits[].new_string, conversation_id and hook_event_name "afterFileEdit". skill-nudge matched the whole file instead of the added text, fell back to a per-process session id (so it nudged on every edit), and both hooks printed Claude's JSON because any hook_event_name counted. The hooks now read the added text and conversation_id, and print JSON only for PostToolUse. The tests use Cursor's documented payload and check session-start's exit code. The docs say Cursor documents no afterFileEdit output, so delivery there is unverified. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… pgsql-query pgsql-jsonb keeps the COALESCE(jsonb_agg(x ORDER BY x), '[]') prescription and points at pgsql-query for why an aggregate over zero rows is NULL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Answer to the review of a5079a7. Everything is pushed; the head is ce0bc72 and both checks pass. Important
Suggestions
The repository URL also now points at 🤖 Generated with Claude Code |
…racts Cursor documents no output for afterFileEdit, so lint findings and skill nudges never reached the Cursor agent. The Cursor config now carries "version": 1 and wires lint-on-save and skill-nudge to postToolUse with matcher Write; both answer with additional_context. session-start runs from the plugin's install directory under Cursor, so it inspects CURSOR_PROJECT_DIR and answers with JSON additional_context. Cursor documents no Write input fields, so the scripts accept file_path or path and the nudge matches the whole file there. The Claude Code path is unchanged; the validator maps PostToolUse to postToolUse and requires the version field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
The first version of
pgsql-coding: PostgreSQL coding standards for Claude Code and Cursor, laid out likego-codingandphp-coding. It targets PostgreSQL 18 (19 additions appear only as labelled hints) and is written for SQL called from Go (pgx v5) and PHP (PDO), with depth on jsonb, indexing, and plan analysis.pgsql-codingversions.mdlists what needs PostgreSQL 17, 18 or 19.pgsql-query,pgsql-jsonb,pgsql-schema,pgsql-indexing,pgsql-performance,pgsql-migrationsSKILL.md; 25 reference files that load only when a task needs them. Every rule cites its source, mostly the PostgreSQL 18 manual./pgsql-lint-setup.squawk.toml(identical toreferences/squawk.toml).pgsql-performance/scripts/plan-summary.pyEXPLAINplans: where the time goes, the node where an estimate error starts, spills, loops, filter waste, JIT share.pgsql-reviewer.sqlfiles, migrations, and Go or PHP query strings.pgsql-plan-analystsession-start,lint-on-save(squawk, opt-in through.squawk.toml),skill-nudge; wired for both hosts.rules/pgsql-context.mdcfor**/*.sql.How it was checked
scripts/validate.pyand its 14 self-tests, 40 hook tests, 21 plan-summary tests,claude plugin validate ., and--check-links: all pass. All 255 cited URLs resolved.postgres:18(18.6) container. The jsonb driver claims were run on pgx v5.11.0 and PHP 8.4 withpdo_pgsql. The plan-summary fixtures are real 18.6 plans.lint-on-savewas run against a real squawk 2.70.0.NOT NULL ... NOT VALIDbefore the backfill. It would have failed every update to an old row on a live table. The same pass fixed two wrong idioms and rules that two skills both stated in full.Eval results
The first full run had 7 cases and 42 runs, and cost $5.45:
What the run showed:
lock_timeout = '1s'beforeCREATE INDEX CONCURRENTLY. On 18.6, any older open transaction makes that build fail and leave an INVALID index. A waiting concurrent build does not block plain DML, so no short timeout is needed there. This is fixed in the last commit. The migrations case now scores with 1.00, without 0.67 (Δ +0.33), and the skill fired and its reference was read in 3 of 3 runs..sqlfiles, nogo.mod, and so no session-start line or nudges. In a real repository the hooks add those signals, but the descriptions still need a triggering pass.Follow-ups (not in this PR)
Cadasto/plugin-marketplace(pgsql-coding@cadasto), pinned to the first tag.claude plugin validatewarning.Checklist
./scripts/validate.sh,python3 scripts/validate.py --selftestand./scripts/hooks-test.shpassclaude plugin validate .passes## Sourcescarries the checked datepython3 scripts/validate.py --check-linkspassesdocs/testing.md)🤖 Generated with Claude Code