Skip to content

test(cli,wizard): A-2 sweep — encrypted-domain residue fix + integration coverage#785

Open
tobyhede wants to merge 8 commits into
remove-v2from
fix/a2-coverage-followup
Open

test(cli,wizard): A-2 sweep — encrypted-domain residue fix + integration coverage#785
tobyhede wants to merge 8 commits into
remove-v2from
fix/a2-coverage-followup

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

What

Follow-up to #783 (the A-2 fail-closed sweep, merged into remove-v2). Two things:

  1. A correctness fix — closes a residue the fail-closed rule didn't cover, where an already-encrypted column could still be dropped.
  2. Integration test coveragefix(cli,wizard): rewrite an encrypted ALTER only for a column the corpus declares #783's unit coverage of the sweep function is thorough; everything above it was thin. Three parallel coverage reviews converged on the same gaps, and this closes the actionable ones.

Correctness fix — non-public-schema and array EQL domains

The corpus index recognised a column as encrypted only in the mangled forms drizzle-kit emits and the literal public schema. Two shapes fell through to the plaintext residue and got rewritten into a data-destroying ADD+DROP+RENAME — a DROP COLUMN on ciphertext the corpus itself declared:

  • a domain in a non-public schema — "email" "app"."eql_v3_text_search"
  • an array of a domain — "email" public.eql_v3_text_search[] (the (?=[\s,;)]|$) tail rejected [)

ENCRYPTED_TYPE_REF now admits any quoted schema and a trailing [:

(?:${MANGLED_TYPE_FORMS}|"[^"]+"\."(?:${ENCRYPTED_DOMAIN})")(?=[\s,;)[]|$)

It feeds only the encrypted index, never the rewrite matcher (the ALTER matcher uses MANGLED_TYPE_FORMS directly), so widening can only move a column from "rewritten" to "flagged as already-encrypted" — it can never cause a new rewrite. Applied to both copies of the sweep; the .changeset/rewriter-never-drops-ciphertext.md guarantee is updated to match rather than weakened, because the gap is actually closed. Two TDD tests per copy, red before the fix.

Coverage

Reviews (CLI command level, wizard integration, outer tiers) mutation-tested rather than eyeballed. The load-bearing findings, all closed here:

  • describeSkipReason was untested repo-wide — replacing its body with a constant left every suite green. It's the guidance a user acts on when a statement is skipped, and source-unknown's text is new. Pinned each of the three reasons to a distinctive substring in both copies.
  • The CLI reporting loop never ran under test. The clack log mock in migration.test.ts and generate-drizzle-migration.test.ts omitted step, which the real clack exports — so the per-statement report threw on its first iteration and was swallowed by the command's catch. Production output was fine; the tests were blind. Added step, which also strengthens the existing near-miss test (it no longer passes via the catch path).
  • source-unknown end to end for eql migration --drizzle: the guidance reaches the user via log.step and the closing "did not fully complete" warning fires.
  • db install's step-5 sweep block had zero tests — every other test's out dir held only the skipped generated file, so the block never executed. Added a genuine rewrite and a source-unknown skip against generateDrizzleMigration.
  • Wizard multi-directory orchestration — the per-directory index is the mechanism the whole fail-closed rule exists to make safe, and every existing test used only drizzle/. Added two tests that put the actionable migration in a non-first candidate directory, so shrinking DRIZZLE_OUT_DIRS to ['drizzle'] or short-circuiting the aggregation loop now fails.
  • Hardened the clean "default to Yes" prompt arm to assert it carries no destruction/flag/unchecked warning phrase — a mis-nested arm in the 4-way ternary would otherwise ship a data-destruction warning to a user with nothing to sweep, undetected.

Deferred (tracked in #786)

Not in this PR, with reasons:

  • Partial-write-then-throw reports a directory as unverified rather than destructive while a live DROP COLUMN sits on disk — a production behaviour change, not just a test, so it wants its own PR.
  • No realistic-corpus fixture (squashed history / drizzle-kit pull baseline) at any tier — larger, and examples/** has no drizzle migration history to build on.
  • No e2e case — judged against packages/cli/AGENTS.md's own triggers the sweep meets none, and it sits behind a project-local drizzle-kit generate that isn't a repo dependency. The recommendation is to first add a read-only --dry-run sweep seam, then one e2e case can assert the report renders legibly; that seam is its own change.
  • A sync-lint for the two duplicated sweep copies — worth having (they're held together only by a comment), but it's tooling, tracked in the spec.

Test plan

  • pnpm --filter stash test — 880 passed
  • pnpm --filter @cipherstash/wizard test — 313 passed, 5 skipped
  • pnpm run code:check — 0 errors
  • Both sweep source copies verified still in sync.

Part of #707

@tobyhede
tobyhede requested a review from a team as a code owner July 25, 2026 00:53
@changeset-bot

changeset-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9c96ff1

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes changesets to release 11 packages
Name Type
@cipherstash/wizard Patch
stash Patch
@cipherstash/e2e Patch
@cipherstash/basic-example Patch
@cipherstash/stack Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/prisma-next Patch
@cipherstash/bench Patch
@cipherstash/test-kit Patch
@cipherstash/prisma-next-example Patch

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 72a06e41-496e-48fc-b1cb-854848bbab76

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/a2-coverage-followup

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.

tobyhede added 3 commits July 25, 2026 11:43
…crypted

The corpus index recognised an encrypted column only in the mangled forms
drizzle-kit emits and the literal `public` schema. A domain installed into
another schema ("app"."eql_v3_text_search") or an array of a domain
(public.eql_v3_text_search[]) fell to the plaintext residue, so an ALTER on
such a column was rewritten into ADD+DROP+RENAME — dropping ciphertext the
corpus itself showed was encrypted.

ENCRYPTED_TYPE_REF now admits any quoted schema and a trailing '[', closing
both. It feeds only the encrypted index, never the rewrite matcher, so the
worst case is a flagged statement, not a new rewrite. Applied to both copies
of the sweep; changeset guarantee updated to match.
describeSkipReason had zero coverage repo-wide — replacing its body with a
constant left every suite green. Its output is the guidance a user acts on
when a statement is skipped, and source-unknown's is new. Pin each reason to
a distinctive substring so a mis-mapped case is caught.
The sweep's unit tests are thorough; everything above them was thin.

- The clack log mock in migration.test.ts and generate-drizzle-migration.test.ts
  omitted `step`, so the per-statement report threw and landed in the command's
  catch — no test ever saw the report block. Add `step`.
- eql migration: assert source-unknown guidance reaches the user and the closing
  warning fires for an undeclared column.
- db install (generateDrizzleMigration): its step-5 sweep block had zero tests —
  every other test's out dir held only the skipped generated file. Add a genuine
  rewrite and a source-unknown skip.
- wizard post-agent: harden the clean default-to-Yes arm to assert it carries no
  warning phrase, and add two multi-directory tests that put the actionable
  migration in a non-first candidate, so shrinking DRIZZLE_OUT_DIRS or
  short-circuiting the aggregation loop fails.
@tobyhede
tobyhede force-pushed the fix/a2-coverage-followup branch from 0508abc to 301f7e0 Compare July 25, 2026 01:43
tobyhede added 5 commits July 25, 2026 12:06
…icit schema

Two ways a corpus could still lose ciphertext to the ADD+DROP+RENAME, both
found by the #772 review. Neither is caught by the fail-closed `declared`
rule, because in both the column really is declared — as plaintext, by the
original CREATE TABLE.

Finding 3 — chained conversions. `indexColumnDeclarations` reads CREATE TABLE,
ADD COLUMN and RENAME, but never the strict matcher's own target. A corpus
carrying an earlier `SET DATA TYPE eql_v2_encrypted` (a stack version that
predates this sweep) left the column looking plaintext, so a later domain
change dropped its ciphertext. Record the conversion as the sweep walks, in
file-sorted order — not in the corpus-wide index, which has no order and would
flag the legitimate first conversion too.

Finding 4 — `"users"` and `"public"."users"` are the same table; Postgres
resolves the unqualified name through `search_path`. drizzle-kit emits
unqualified, while hand-written SQL and this sweep's own renderSafeAlter output
are qualified, so a corpus mixing the two split one column across two keys: the
already-encrypted guard missed, and the ALTER dropped ciphertext. Collapse the
implicit schema in `columnKey`. Only that one — `"app"."users"` stays distinct,
and there is no case folding, since TABLE_REF matches quoted identifiers only.

Both fixes land in both copies of the rewriter. 7 tests each: the two
destructive cases, the wrong-reason case finding 4 also produced
(`source-unknown` for a column declared two files up), and four guards for the
ways a naive fix breaks — the first conversion in a chain, a chain across
different columns, a commented-out earlier ALTER, and a real non-public schema.
…ning for comments

Quote parity is a whole-file property. Anything that makes the scanner
disagree with Postgres about where a literal ENDS shifts every token after
it — so a commented-out ALTER downstream reads as live and is rewritten into
a live DROP COLUMN.

`isInsideCommentOrString` left two such gaps. A `$$ … $$` body was untracked
on the stated grounds that mis-reading one can only make us SKIP a rewrite;
that holds for an unterminated literal but not for an over-terminated one, and
an odd apostrophe count inside the body (`$$ don't $$` — a function body, a
comment, any prose) ends a literal EARLY. And `endOfQuoted` knew only the
doubled-delimiter escape, so `E'a\'b'` read as closing at the escaped quote.

Skip dollar-quoted bodies whole, and give `endOfQuoted` a backslashEscapes
mode selected when the opening quote is an `E''` prefix (guarded so an
identifier merely ending in `e` does not trigger it). Both copies.

Four tests each. The two destructive shapes, a tagged `$fn$` variant, and a
guard that a live ALTER *following* a dollar-quoted body is still rewritten —
that one failed before this change too, in the over-skip direction the old
docblock had assumed was the only risk.
…ssing it

CREATE_TABLE_RE carried a lazy `([\s\S]*?)\)\s*;` body, so it stopped at the
first `);` anywhere in the file. That can sit inside a `--` comment or a string
DEFAULT — `"id" text, -- pk (uuid);` is legal and unremarkable — and the body
was then truncated at that point. Every column declared after it disappeared
from BOTH indexes.

For an encrypted column that means the already-encrypted guard never sees it.
On its own that yields a wrong reason (`source-unknown` for a column declared
two lines up). Composed with a `declared` record from elsewhere in the corpus —
a table dropped and recreated in a later migration — the fail-closed rule is
satisfied too, and the ADD+DROP+RENAME drops a column full of ciphertext.

Match only `CREATE TABLE … (` and locate the closing `)` by scanning candidate
`)\s*;` positions for the first one `isInsideCommentOrString` says is live.
Nested parens (`numeric(10,2)`, `CHECK (x > 0)`) are not followed by `;`, so
requiring the terminator still excludes them. An unclosed statement is skipped
rather than indexed to end-of-file.

Three tests each: the comment truncation, the DEFAULT-string truncation, and
the composed case that actually destroys data.
…ls CI

The rewriter lives twice — packages/wizard/src/lib and packages/cli/src/commands/db
— at ~99% similarity, and neither package depends on the other. Four #772 review
findings each needed the identical fix in both places; a fix landing in one still
passes that package's own suite, and this code emits DROP COLUMN.

Extraction is not available this cycle: packages/utils has no package.json,
@cipherstash/test-kit is private and build-less, and both bin entries set
skipNodeModulesBundle, so a shared workspace package would survive into dist as
an unresolvable bare specifier. That leaves publishing a new package into a
fixed mid-RC release group or adding noExternal to two CLI bundles — both worse
than the drift they would prevent, for now.

So compare instead. Everything outside an explicit `#region wizard-only` marker
must match byte-for-byte modulo comments, imports and the tool name in the
emitted header. Verified the guard actually fires: perturbing either copy fails
with a message naming both files and pointing at the region marker.
The wizard cannot discover a project's configured drizzle-kit `out`, so it
tries drizzle/, migrations/ and src/db/migrations/. Two of those are generic
names — Knex, node-pg-migrate, Flyway and hand-rolled psql all use them — and
this sweep emits DROP COLUMN.

So a project whose out is drizzle/ but which also keeps a hand-maintained
migrations/ had that second directory rewritten into ADD+DROP+RENAME, in a
directory the wizard was never pointed at. #783's fail-closed rule does not
help: it indexes per directory, and a real migration history declares its own
columns, so the guard passes and the rewrite proceeds. Worse, the emitted
`--> statement-breakpoint` is a valid SQL line comment, so the mangled file
stays runnable by whichever tool actually owns it.

Gate on meta/_journal.json, which drizzle-kit writes on first generate and
maintains after, and which none of the others produce. A directory holding
.sql files but no journal is reported rather than silently passed over — a
genuine drizzle output whose meta/ went missing must not just look clean.

Both CLI call sites are unaffected; they pass one explicit --out and never
sweep. The multi-directory tests added by the previous commit keep their
intent — their fixtures are now journal-bearing, so shrinking DRIZZLE_OUT_DIRS
or short-circuiting the aggregation loop still fails them — and a new pair
covers the foreign-directory case at both the sweep and command layers.

Also adds the afterEach that post-agent.test.ts never had: all five tests in
that describe leaked a temp tree per run.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve.

The encrypted-domain residue fix is correct and provably safe: the widening is confined to the detection index (ENCRYPTED_TYPE_REF) and never touches the rewrite matcher (ALTER_COLUMN_TO_ENCRYPTED_RE still uses MANGLED_TYPE_FORMS), so the worst case is a column being flagged "already encrypted" rather than rewritten — the safe direction for a tool that emits DROP COLUMN on ciphertext. Both new correctness tests fail without their production fix; the tests write real .sql files and invoke the real sweep functions (no assertions on mock behavior), and the clack.log.step mock addition fixes a real incomplete-mock blindness. No test-only methods added to production. Changeset satisfied (the existing rewriter-never-drops-ciphertext.md is strengthened).

Non-blocking: the two rewriter copies remain duplicated, held together only by the new sync test — honestly acknowledged and deferred to #786 with a sound rationale. The describeSkipReason substring assertions are brittle to copy edits (justified, since the wording is the data-loss-remediation contract). Nice mutation-testing discipline surfacing the blind spots.

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.

2 participants