feat(cli): offline schema source (--sql-file / --snapshot) for lint/fix/generate (#225)#231
Conversation
|
Thanks for taking this on — #225 is a genuinely useful capability and a lot of the hard parts here are done right. I ran the full gate locally (the fork CI didn't auto-trigger): There's one blocker though, and it's the core soundness invariant the PR sets out to guarantee — so worth getting exactly right before merge. CRITICAL — offline
Because -- sec035.sql
CREATE TABLE public.accounts (id uuid, tenant_id integer, email text);
ALTER TABLE public.accounts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON public.accounts USING (tenant_id = current_setting('app.tenant_id')::int);
CREATE UNIQUE INDEX accounts_email_key ON public.accounts (email);That's the one outcome the feature promises can't happen — an offline HIGH — the skip-set is source-blind, so Both point at the same fix: make the inert decision source-aware — key it on which fields the resolved MED — CI mypy. Everything else looked great — this is close. Happy to re-review once the skip-set is source-aware. |
The static inert-rule list false-cleaned two ways (PR pgrls#231 review): - CRITICAL: SEC035/SEC041/SEC043/SEC048 read model fields the offline builder never populates but were absent from the inert set, so an offline `--sql-file` run produced zero findings, omitted them from `skipped_rules`, and passed `--require-full-coverage` (exit 0) while a live lint would flag a real leak. - HIGH: the same static set force-skipped all rules on a `--snapshot`, so `--require-full-coverage` failed even on a fully-complete snapshot. Replace the blanket list with `_CATALOG_DEPENDENT_RULES`, mapping each catalog-dependent rule to the snapshot version at which every field it reads is serialized. `inert_rule_ids` is now source-aware: a `sql=` schema inerts every such rule (the DDL builder populates none of those fields); a `snapshot` inerts a rule only when its declared version predates the rule's threshold (an older capture decodes the field empty and the rule would silently no-op); `database_url` inerts nothing. `resolve_schema` returns the snapshot version so the CLI/MCP can scope this. PERF005 leaves the set entirely — it reads the `--perf` runtime artifact, not a schema field, so it no-ops without `--perf` on every source exactly as on a live database. The threshold is the MAX over every field a rule reads, including fields reached through a shared helper: SEC041/SEC043 gate on `_is_directly_reachable`, which reads `Table.column_grants` (v8), so SEC041's threshold is 8 (not partition_of's v2) — otherwise a v3-v7 snapshot would false-clean a column-grant-only partition bypass. Also fixes the 4 `mypy --strict` errors on `cli._fix_dispatch` / `_generate_dispatch` and the bare `dict` on the json formatters' `extra`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
A linter that says "all clear" while a real leak walks past is just an optimist with a CLI — so let's make this one a pessimist where it can't see. Pushed in 8c5246e. CRITICAL — HIGH — snapshot over-skip. Fixed, and via the route you pointed at: the inert decision is now source- and version-aware rather than a blanket list.
One I caught on the way out, in the same spirit as yours: the threshold has to be the MAX over every field a rule reads — including fields reached through a shared helper. SEC041/SEC043 gate on MED — mypy. Fixed: Tests: a regression test per offline-noop rule asserting it lands in |
|
Re-reviewed 8c5246e — the rework is genuinely strong. I verified each round-1 finding is closed: the four catalog-dependent rules are now surfaced ( One residual issue, and it's exactly that instinct applied one rule short — CRITICAL. Repro (a textbook PERF004 — a plain index defeated by CREATE TABLE public.users (id uuid, email text);
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
CREATE INDEX users_email_idx ON public.users (email);
CREATE POLICY p ON public.users USING (lower(email) = current_setting('app.email', true));Fix is one line: I enumerated all 61 rules against the un-populated fields (incl. every cross-rule helper import) and PERF004 is the only remaining gap — everything else is correctly mapped or legitimately fires offline (SEC001/SEC022/SEC025 read partition/inherits/views only to skip, never silently no-op; SEC045/SEC030 read column-grants/column-details which the builder does populate). All 20 thresholds check out against the model field-versions. So with PERF004 added, the map is complete and the contract holds. Really nice work on the rest. |
|
Caught me one rule short — and through exactly the trapdoor my own CAUTION comment was pointing at. Fixed in 7d94195. PERF004 — offline false-clean. Mapped: Since this is the second miss via a cross-rule helper, I re-walked every Gate: ruff clean, |
|
Verified 7d94195 — PERF004 is mapped (threshold 7), your repro now exits 1 with One mechanical thing before it can merge: the branch now shows CONFLICTING — the sole conflict is |
…ources Pure move of the FastMCP-agnostic schema-resolution core out of the mcp package so the CLI can reuse it without importing fastmcp. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
inert_rule_ids names the catalog-only rules to skip; snapshots are treated conservatively (same set as sql=, never a silent no-op). schema_source_warnings gains a typed command kwarg; command=None keeps the MCP wording unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shared --sql-file (repeatable, stdin)/--snapshot Click options and the helper that reads/concats the source, byte-caps and recursion-guards untrusted input, builds the Schema via pgrls.schema_sources, and prints the command-aware caveat to stderr. Plus the exclusivity / reject-apply / offline-config helpers. Not yet wired into a command. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ommands Behavior-preserving extraction of the emit/output-mode tails so the upcoming offline branches reuse one code path instead of copy-pasting the byte-deterministic --output write. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erage) Offline lint builds the Schema from raw DDL (repeatable / stdin) or a snapshot, explicitly skips + reports the catalog-only rules, and carries the partial-coverage fact to the gate: schema_source + skipped_rules in --format json, and --require-full-coverage to fail a partial offline run. Guards against combining with --database-url / --migrations. Closes part of pgrls#225. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Offline fix builds the Schema from raw DDL or a snapshot and emits the mechanical fixes to stdout / --output with an offline-provenance header. --apply is rejected offline (no live connection), matching the emit-only MCP fix tool. Closes part of pgrls#225. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Offline generate scaffolds RLS from raw DDL or a snapshot, emitting to stdout (with an offline-provenance header) / --output and a generation-scoped caveat on stderr. --apply is rejected offline. Adds a regression pin that verify still refuses the offline flags. Closes part of pgrls#225. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the "Live database only" limitation into a documented feature, add a no-Docker tier (with the partial-coverage caveat and --require-full-coverage) to the README, narrow the github-formatter note (source ranges remain a pgrls#227 follow-on), and add a CHANGELOG entry. Closes pgrls#225. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…kstop Remove two stray plan-authoring comments and assert the live --format json payload carries neither coverage key (schema_source nor skipped_rules). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hot byte-cap PERF003 fired on every offline RLS policy (parser can't see CREATE INDEX); add it to the catalog-only inert set and skip inert-rule fixers in offline fix so no bogus CREATE INDEX is emitted. Complete the inert list for honest skipped_rules accounting (SEC023 reads bypassrls_roles, VIEW002 reads schema.views — both never populated offline; SEC036/SEC041 can fire on offline-available data and are NOT added). Apply the 8 MiB input cap to --snapshot too (was guarded only for --sql-file). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rate --config --require-full-coverage and the skipped-rule notice now reflect only the rules that would have run (so --rule SEC004 isn't failed by unrelated catalog skips, and --rule SEC016 offline is surfaced not silent). Offline generate now validates --config like fix/live. Reject --update-baseline + --require-full-coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fix caveats Offline fix/generate --output migrations now carry the offline caveat instead of the false "generated from a snapshot of the database" header. _fix_dispatch owns the offline header (collapses the re-inlined offline branch). Correct the CLI soundness warning (--database-url, no snapshot-as-full-coverage) and add --require-full-coverage discoverability + --snapshot input-only wording. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nflicts, check Pin the previously-untested offline paths for fix/generate (--snapshot, repeated --sql-file, stdin, --database-url conflict, fix --check) and the coverage gate on the snapshot source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The comment claimed the full inert set feeds skipped_rules; it is gating_skipped (rules-in-play), consistent with the notice and the coverage gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The static inert-rule list false-cleaned two ways (PR pgrls#231 review): - CRITICAL: SEC035/SEC041/SEC043/SEC048 read model fields the offline builder never populates but were absent from the inert set, so an offline `--sql-file` run produced zero findings, omitted them from `skipped_rules`, and passed `--require-full-coverage` (exit 0) while a live lint would flag a real leak. - HIGH: the same static set force-skipped all rules on a `--snapshot`, so `--require-full-coverage` failed even on a fully-complete snapshot. Replace the blanket list with `_CATALOG_DEPENDENT_RULES`, mapping each catalog-dependent rule to the snapshot version at which every field it reads is serialized. `inert_rule_ids` is now source-aware: a `sql=` schema inerts every such rule (the DDL builder populates none of those fields); a `snapshot` inerts a rule only when its declared version predates the rule's threshold (an older capture decodes the field empty and the rule would silently no-op); `database_url` inerts nothing. `resolve_schema` returns the snapshot version so the CLI/MCP can scope this. PERF005 leaves the set entirely — it reads the `--perf` runtime artifact, not a schema field, so it no-ops without `--perf` on every source exactly as on a live database. The threshold is the MAX over every field a rule reads, including fields reached through a shared helper: SEC041/SEC043 gate on `_is_directly_reachable`, which reads `Table.column_grants` (v8), so SEC041's threshold is 8 (not partition_of's v2) — otherwise a v3-v7 snapshot would false-clean a column-grant-only partition bypass. Also fixes the 4 `mypy --strict` errors on `cli._fix_dispatch` / `_generate_dispatch` and the bare `dict` on the json formatters' `extra`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PERF004 gates every emission on `perf003._has_leading_column_index`, which reads `Table.indexes` — never populated by the offline DDL builder. It was missing from `_CATALOG_DEPENDENT_RULES`, so an offline `--sql-file` run produced zero PERF004 findings, omitted it from `skipped_rules`, and passed `--require-full-coverage` (exit 0) while a live lint would flag a function-wrapped column defeating its index. Same class as the SEC035/041/043/048 bug, via the same shared-helper path the map's CAUTION comment warns about — PERF003 (reads `indexes` directly) was mapped, but PERF004 reaching the same field through PERF003's helper slipped by. Threshold 7 (the helper reads only the v7-baseline `Index.columns`, not `is_primary` at v13). The parametrized `test_offline_noop_rule_*` tests cover it automatically now that it is mapped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7d94195 to
5513bf1
Compare
|
Went ahead and rebased this onto Re-ran the full gate on the rebased result — ruff, |
|
Merged — thank you, @ozimakov. This was a genuinely hard feature to land soundly (the offline-coverage contract has a lot of sharp edges), and you took every round of review in stride and even out-enumerated the helper-import traps yourself. Your second merged contribution to pgrls, and a meaningful one — |
Summary
Promotes the offline schema-analysis engine the MCP server already ships up to the CLI:
pgrls lint,fix, andgeneratecan now analyze a schema with no live Postgres and no Docker via two new mutually-exclusive sources:--sql-file PATH— raw DDL; repeatable (concatenated in order),-reads stdin--snapshot PATH— apgrls snapshotartifactThis makes the common CI gate — "lint the RLS in this PR's
.sqldiff" — a zero-dependency step. Closes #225.verifyis deliberately excluded: offline it could emit a false-positiveLEAK(a counterexample a fuller, un-passed schema would prove safe), contradicting its "never reports a leak it cannot exhibit" guarantee. It returns under its own design.Soundness contract
Offline
lint/fix/generatecan only ever under-report:--format jsoncarries additiveschema_source+skipped_ruleskeys on offline runs (live JSON is byte-identical), andlint --require-full-coveragefails a partial run. An offline exit 0 is not a clean bill of health unless paired with that flag.--snapshotis treated conservatively (same inert set as--sql-file) so an older/partial snapshot can't silently no-op a catalog rule.fix/generateare emit-only offline (--applyrejected); emitted SQL — including--outputmigration files — carries an offline-provenance header.RecursionError→bad_sql, and an 8 MiB byte cap on both--sql-fileand--snapshot.How it was built
Brainstormed → spec → plan → executed task-by-task (TDD, per-task review), then put through a five-role parallel review (correctness, soundness/security, CLI-UX, architecture, tests). That review caught a real contract break the per-task reviews missed — PERF003 (missing-index) fired on every offline RLS policy because the parser doesn't model
CREATE INDEXand the rule fires on index absence. Fixed (inerted offline, still fires live, fixer suppressed offline), along with coverage-gate scoping, offlinegenerate --configvalidation,--outputprovenance, and a batch of UX/wording fixes.Tests
Full suite 3332 passed, ruff clean. New coverage spans every command × source × stdin/multi-file × guard × exit-code/coverage cell.
Docs
AGENTS.md "Live database only" limitation flipped to a documented feature; README gains a no-Docker tier;
github-formatter note narrowed (source ranges remain a #227 follow-on); CHANGELOG entry added.🤖 Generated with Claude Code