diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7f970e250..94049a776 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,43 @@ on: - "**" jobs: + # Biome format + lint. Its own job (no DB, no credentials, node-agnostic) so it + # surfaces as a distinct check and runs once rather than per Node matrix leg. + # Gates on ERRORS only — warnings are allowed, so the `no-type-erasing-assertions` + # plugin (at `warn`) surfaces new `as any`/`never`/`unknown` without blocking. + # Tightening to fail on warnings is tracked in #625. + lint: + name: Lint (Biome) + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6.0.8 + name: Install pnpm + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + # Node 22 is the supply-chain hardening baseline every pnpm-using job + # must pin (enforced by e2e/tests/supply-chain.e2e.test.ts). Lint is + # runtime-agnostic, so a single pinned version is fine. + node-version: 22 + cache: 'pnpm' + + # node-pty's install hook falls back to `node-gyp rebuild` when no + # linux-x64 prebuild matches; pnpm/action-setup v6 no longer ships it. + - name: Install node-gyp + run: npm install -g node-gyp + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Biome check (format + lint) + run: pnpm run code:check + run-tests: name: Run Tests (Node ${{ matrix.node-version }}) runs-on: blacksmith-4vcpu-ubuntu-2404 diff --git a/AGENTS.md b/AGENTS.md index 97d513d04..6c6f128b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,9 +174,22 @@ Three rules to remember when editing CI or pnpm config: - **Formatting/Linting**: Use Biome ```bash -pnpm run code:fix +pnpm run code:fix # format + lint, auto-fixing what it can +pnpm run code:check # read-only; this is what CI runs ``` + CI runs `code:check` (in `tests.yml`) and gates on **errors** — warnings are + allowed (tracked for tightening). So `code:fix` must leave the tree + error-free before you push. + + A Biome GritQL plugin (`biome-plugins/no-type-erasing-assertions.grit`) warns + on `as any` / `as never` / `as unknown` in `src` — type-erasing assertions that + silence the checker instead of narrowing. Fix the type or use a specific + assertion; suppress a deliberate case with `// biome-ignore lint/plugin: + `. The plugin is scoped to source via an `overrides` entry in + `biome.json` (test/integration files excluded) — see the plugin file's header + for why it must be scoped-in rather than globally-enabled-and-exempted. + - **Build**: `pnpm run build` (Turborepo + tsup per package) - **Test**: `pnpm --filter test` for targeted iterations - **Releases**: Use Changesets diff --git a/biome-plugins/no-type-erasing-assertions.grit b/biome-plugins/no-type-erasing-assertions.grit new file mode 100644 index 000000000..2aafe8a83 --- /dev/null +++ b/biome-plugins/no-type-erasing-assertions.grit @@ -0,0 +1,33 @@ +// Flags type-erasing assertions: `x as any`, `x as never`, `x as unknown` +// (the last also catches the inner half of `x as unknown as T` double-casts). +// +// These punch a hole through the type system rather than narrowing a value, so +// a real type mismatch can hide behind them (e.g. passing the wrong shape to a +// function whose signature you've silenced). Prefer fixing the underlying type, +// narrowing with a guard, or a precise `as SpecificType`. +// +// NOT flagged: `as const` (a widening-freeze, not an erasure), a plain +// `as SpecificType`, or `never` used as a *type annotation* (e.g. a contravariant +// `param: never`) — those are legitimate. +// +// Warning, not error: the repo has a large existing backlog, and this is a +// ratchet to keep NEW erasures visible. Intentional cases take a +// `// biome-ignore lint/plugin: ` suppression. +// +// Wiring note: this plugin is enabled via an `overrides` entry in biome.json +// that INCLUDES source globs and negates test globs — it is NOT enabled at the +// top level. Biome applies top-level `plugins` to every file, and +// `overrides[].plugins: []` does NOT disable them, so "enable globally + exempt +// tests" silently lints tests too. Scoping the plugin in via the override is the +// only way to exempt test doubles (which legitimately force-cast). +or { + `$e as any`, + `$e as never`, + `$e as unknown` +} where { + register_diagnostic( + span = $e, + message = "Avoid type-erasing assertions (`as any` / `as never` / `as unknown`) — they silence the checker instead of narrowing. Fix the underlying type, use a type guard, or assert a specific type. Suppress an intentional case with `// biome-ignore lint/plugin: `.", + severity = "warn" + ) +} diff --git a/biome.json b/biome.json index ca315148c..17d9c3f47 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,16 @@ "!**/package.json", "!**/.turbo/", "!**/dist", - "!**/nix/store" + "!**/nix/store", + "!**/*.grit", + "!**/*.generated.ts", + "!**/contract.json", + "!**/contract.d.ts", + "!**/end-contract.json", + "!**/end-contract.d.ts", + "!**/migrations/**/migration.json", + "!**/migrations/**/ops.json", + "!**/migrations/**/refs" ] }, "formatter": { @@ -30,5 +39,20 @@ "noThenProperty": "off" } } - } + }, + "overrides": [ + { + "includes": [ + "**/src/**", + "examples/**", + "!**/__tests__/**", + "!**/*.test.ts", + "!**/*.test-d.ts", + "!**/*.spec.ts", + "!**/integration/**", + "!**/tests/**" + ], + "plugins": ["./biome-plugins/no-type-erasing-assertions.grit"] + } + ] } diff --git a/e2e/tests/package-managers.e2e.test.ts b/e2e/tests/package-managers.e2e.test.ts index 85f65414b..52df50afc 100644 --- a/e2e/tests/package-managers.e2e.test.ts +++ b/e2e/tests/package-managers.e2e.test.ts @@ -56,7 +56,8 @@ describe('CLI init providers — package-manager-aware Next Steps', () => { { label: 'drizzle', create: createDrizzleProvider, - firstStep: (r) => `Set up your database: ${r} stash eql install --drizzle`, + firstStep: (r) => + `Set up your database: ${r} stash eql install --drizzle`, }, { label: 'supabase', diff --git a/examples/prisma/migrations/app/20260709T1034_initial/migration.ts b/examples/prisma/migrations/app/20260709T1034_initial/migration.ts index 11c035c3a..711d9eb64 100755 --- a/examples/prisma/migrations/app/20260709T1034_initial/migration.ts +++ b/examples/prisma/migrations/app/20260709T1034_initial/migration.ts @@ -1,13 +1,18 @@ #!/usr/bin/env -S node -import { cipherstashAddSearchConfig } from '@prisma-next/extension-cipherstash/migration'; -import { Migration, MigrationCLI, col, primaryKey } from '@prisma-next/postgres/migration'; +import { cipherstashAddSearchConfig } from '@prisma-next/extension-cipherstash/migration' +import { + col, + Migration, + MigrationCLI, + primaryKey, +} from '@prisma-next/postgres/migration' export default class M extends Migration { override describe() { return { from: null, to: 'sha256:b8c4febc9397cf1b68293cdb3b2afe9f568db6967f428e3f207e32575a2bc2fa', - }; + } } override get operations() { @@ -34,17 +39,30 @@ export default class M extends Migration { notNull: true, codecRef: { codecId: 'cipherstash/string@1', - typeParams: { equality: true, freeTextSearch: true, orderAndRange: true }, + typeParams: { + equality: true, + freeTextSearch: true, + orderAndRange: true, + }, }, }), col('emailverified', 'eql_v2_encrypted', { notNull: true, - codecRef: { codecId: 'cipherstash/boolean@1', typeParams: { equality: true } }, + codecRef: { + codecId: 'cipherstash/boolean@1', + typeParams: { equality: true }, + }, + }), + col('id', 'text', { + notNull: true, + codecRef: { codecId: 'pg/text@1' }, }), - col('id', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), col('preferences', 'eql_v2_encrypted', { notNull: true, - codecRef: { codecId: 'cipherstash/json@1', typeParams: { searchableJson: true } }, + codecRef: { + codecId: 'cipherstash/json@1', + typeParams: { searchableJson: true }, + }, }), col('salary', 'eql_v2_encrypted', { notNull: true, @@ -80,9 +98,21 @@ export default class M extends Migration { index: 'ore', castAs: 'date', }), - cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'unique' }), - cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'match' }), - cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'ore' }), + cipherstashAddSearchConfig({ + table: 'users', + column: 'email', + index: 'unique', + }), + cipherstashAddSearchConfig({ + table: 'users', + column: 'email', + index: 'match', + }), + cipherstashAddSearchConfig({ + table: 'users', + column: 'email', + index: 'ore', + }), cipherstashAddSearchConfig({ table: 'users', column: 'emailverified', @@ -107,8 +137,8 @@ export default class M extends Migration { index: 'ore', castAs: 'double', }), - ]; + ] } } -MigrationCLI.run(import.meta.url, M); +MigrationCLI.run(import.meta.url, M) diff --git a/examples/prisma/src/index.ts b/examples/prisma/src/index.ts index 885d3cddf..02320c81c 100644 --- a/examples/prisma/src/index.ts +++ b/examples/prisma/src/index.ts @@ -118,7 +118,9 @@ async function main() { } async function clearUsers(): Promise { - const removed = await db.orm.public.User.where((u) => u.id.isNotNull()).deleteCount() + const removed = await db.orm.public.User.where((u) => + u.id.isNotNull(), + ).deleteCount() if (removed > 0) { console.log(`--- Cleanup ---\nRemoved ${removed} existing user row(s).\n`) } @@ -212,7 +214,9 @@ async function equalityQueryOnEmailVerified(): Promise { async function sortByEmailAsc(): Promise { console.log('\n--- cipherstashAsc (bare-column ORDER BY) ---') - const rows = await db.orm.public.User.orderBy((u) => cipherstashAsc(u.email)).all() + const rows = await db.orm.public.User.orderBy((u) => + cipherstashAsc(u.email), + ).all() await decryptAll(rows) for (const row of rows) { console.log(` ${row.id}: email=${await row.email.decrypt()}`) diff --git a/examples/prisma/test/e2e/global-setup.ts b/examples/prisma/test/e2e/global-setup.ts index 3fb218342..82c7dafdb 100644 --- a/examples/prisma/test/e2e/global-setup.ts +++ b/examples/prisma/test/e2e/global-setup.ts @@ -119,20 +119,14 @@ export default async function setup(): Promise<() => Promise> { // is for the `pnpm start` demo loop). process.env['DATABASE_URL'] = HARNESS_DATABASE_URL - const apply = spawnSync( - 'pnpm', - ['exec', 'prisma-next', 'migrate', '--yes'], - { - cwd: exampleDir, - stdio: 'pipe', - env: process.env, - timeout: MIGRATION_APPLY_TIMEOUT_MS, - }, - ) + const apply = spawnSync('pnpm', ['exec', 'prisma-next', 'migrate', '--yes'], { + cwd: exampleDir, + stdio: 'pipe', + env: process.env, + timeout: MIGRATION_APPLY_TIMEOUT_MS, + }) if (apply.error || apply.signal || apply.status !== 0) { - throw new Error( - describeSpawnFailure('`prisma-next migrate`', apply), - ) + throw new Error(describeSpawnFailure('`prisma-next migrate`', apply)) } // Clean slate for the suite. The `users` table is the only data-bearing diff --git a/examples/prisma/test/e2e/str-range.e2e.test.ts b/examples/prisma/test/e2e/str-range.e2e.test.ts index c98bda626..e8d4e2295 100644 --- a/examples/prisma/test/e2e/str-range.e2e.test.ts +++ b/examples/prisma/test/e2e/str-range.e2e.test.ts @@ -74,7 +74,9 @@ describe('EncryptedString orderAndRange e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashAsc orders alphabetically (bare-column ORDER BY on string)', async () => { - const rows = await db.orm.public.User.orderBy((u) => cipherstashAsc(u.email)).all() + const rows = await db.orm.public.User.orderBy((u) => + cipherstashAsc(u.email), + ).all() expect(rows.map((r) => r.id)).toEqual([ 'e2e-str-0', 'e2e-str-1', diff --git a/package.json b/package.json index 9638c0ebb..fc0d7ef6c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dev": "turbo dev --filter './packages/*'", "clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules", "code:fix": "biome check --write", + "code:check": "biome check", "lint:runners": "node scripts/lint-no-hardcoded-runners.mjs", "lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs", "release": "pnpm run build && changeset publish", diff --git a/packages/cli/src/__tests__/config.test.ts b/packages/cli/src/__tests__/config.test.ts index d3094584f..5189bc2f7 100644 --- a/packages/cli/src/__tests__/config.test.ts +++ b/packages/cli/src/__tests__/config.test.ts @@ -54,37 +54,34 @@ describe('loadStashConfig', () => { it.each([ ['stash', `Cannot find module 'stash'`], ['@cipherstash/stack', `Cannot find package '@cipherstash/stack'`], - ])( - 'translates a missing `%s` module into actionable guidance (#579)', - async (pkg, message) => { - fs.writeFileSync( - path.join(tmpDir, 'stash.config.ts'), - `import 'stash'\nexport default {}`, - ) - process.cwd = () => tmpDir - vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit') - }) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - const moduleErr = Object.assign(new Error(message), { - code: 'MODULE_NOT_FOUND', - }) - const { createJiti } = await import('jiti') - vi.mocked(createJiti).mockReturnValue({ - import: vi.fn().mockRejectedValue(moduleErr), - } as never) - - const { loadStashConfig } = await import('@/config/index.ts') - await expect(loadStashConfig()).rejects.toThrow('process.exit') - - const output = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n') - expect(output).toContain(`\`${pkg}\` is not installed`) - expect(output).toContain('stash init') - // The raw jiti stack trace must NOT be forwarded to the user. - expect(output).not.toContain('Failed to load') - }, - ) + ])('translates a missing `%s` module into actionable guidance (#579)', async (pkg, message) => { + fs.writeFileSync( + path.join(tmpDir, 'stash.config.ts'), + `import 'stash'\nexport default {}`, + ) + process.cwd = () => tmpDir + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const moduleErr = Object.assign(new Error(message), { + code: 'MODULE_NOT_FOUND', + }) + const { createJiti } = await import('jiti') + vi.mocked(createJiti).mockReturnValue({ + import: vi.fn().mockRejectedValue(moduleErr), + } as never) + + const { loadStashConfig } = await import('@/config/index.ts') + await expect(loadStashConfig()).rejects.toThrow('process.exit') + + const output = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n') + expect(output).toContain(`\`${pkg}\` is not installed`) + expect(output).toContain('stash init') + // The raw jiti stack trace must NOT be forwarded to the user. + expect(output).not.toContain('Failed to load') + }) it('still surfaces the raw error for unrelated config load failures', async () => { fs.writeFileSync(path.join(tmpDir, 'stash.config.ts'), 'export default {}') @@ -157,7 +154,9 @@ describe('loadEncryptConfig', () => { let originalCwd: () => string beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-encrypt-config-test-')) + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'stash-encrypt-config-test-'), + ) originalCwd = process.cwd }) diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 1091495e2..0e7e418a7 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -60,9 +60,6 @@ export const messages = { eql: { unknownSubcommand: 'Unknown eql subcommand', }, - eql: { - unknownSubcommand: 'Unknown eql subcommand', - }, db: { unknownSubcommand: 'Unknown db subcommand', /** Warning shown when a deprecated `db ` alias for `eql ` is used. */ diff --git a/packages/prisma-next/test/cipherstash-codec.test.ts b/packages/prisma-next/test/cipherstash-codec.test.ts index 07896f6f6..3375bf4c6 100644 --- a/packages/prisma-next/test/cipherstash-codec.test.ts +++ b/packages/prisma-next/test/cipherstash-codec.test.ts @@ -124,7 +124,10 @@ describe('planFieldEventOperations driving the cipherstash hook', () => { storage: new SqlStorage({ storageHash: 'sha256:test' as StorageHashBase, namespaces: { - __unbound__: buildSqlNamespace({ id: '__unbound__', entries: { table: tables } }), + __unbound__: buildSqlNamespace({ + id: '__unbound__', + entries: { table: tables }, + }), }, }), domain: { namespaces: { __unbound__: { models: {} } } }, diff --git a/packages/prisma-next/test/descriptor.test.ts b/packages/prisma-next/test/descriptor.test.ts index 6956128cd..96cfe2df6 100644 --- a/packages/prisma-next/test/descriptor.test.ts +++ b/packages/prisma-next/test/descriptor.test.ts @@ -121,7 +121,8 @@ describe('cipherstash extension descriptor (contract-space package layout)', () headRefHash: space.headRef.hash, // The emit pipeline hashes through the SQL family's // canonicalization hooks; the recompute must use the same ones. - shouldPreserveEmpty: sqlContractCanonicalizationHooks.shouldPreserveEmpty, + shouldPreserveEmpty: + sqlContractCanonicalizationHooks.shouldPreserveEmpty, sortStorage: sqlContractCanonicalizationHooks.sortStorage, }), ).not.toThrow() diff --git a/packages/prisma-next/test/operator-lowering-equality.test.ts b/packages/prisma-next/test/operator-lowering-equality.test.ts index f74290ffe..694d0759b 100644 --- a/packages/prisma-next/test/operator-lowering-equality.test.ts +++ b/packages/prisma-next/test/operator-lowering-equality.test.ts @@ -116,9 +116,7 @@ describe('cipherstash operator lowering — equality extensions', () => { `"SELECT "user"."id" AS "id" FROM "user" WHERE NOT eql_v2.eq("user"."email", $1::eql_v2_encrypted)"`, ) expect(lowered.params).toHaveLength(1) - expect(literalParamValue(lowered.params[0])).toBeInstanceOf( - EncryptedString, - ) + expect(literalParamValue(lowered.params[0])).toBeInstanceOf(EncryptedString) }) it('lowers cipherstashInArray with a single element to a one-term OR', () => { diff --git a/packages/prisma-next/test/operator-lowering.test.ts b/packages/prisma-next/test/operator-lowering.test.ts index 3ec1038f2..6d276bb76 100644 --- a/packages/prisma-next/test/operator-lowering.test.ts +++ b/packages/prisma-next/test/operator-lowering.test.ts @@ -145,9 +145,7 @@ describe('cipherstash operator lowering — per-codec envelope dispatch', () => const lowered = makeAdapter().lower(selectWithWhere(predicate), { contract, }) - expect(literalParamValue(lowered.params[0])).toBeInstanceOf( - EncryptedBigInt, - ) + expect(literalParamValue(lowered.params[0])).toBeInstanceOf(EncryptedBigInt) }) it('cipherstashGt on a date column wraps the value in EncryptedDate', () => { diff --git a/packages/wizard/src/__tests__/interface.test.ts b/packages/wizard/src/__tests__/interface.test.ts index 33172a8b8..b81a25911 100644 --- a/packages/wizard/src/__tests__/interface.test.ts +++ b/packages/wizard/src/__tests__/interface.test.ts @@ -76,21 +76,22 @@ describe('wizardCanUseTool', () => { // The doctrine tells the agent to add placeholder keys to `.env.example`. // The guard covers Edit and Write as well as Read, so a blanket `.env.` // rule made the file the agent is told to write unreachable. - it.each(['.env.example', '.env.sample', '.env.template'])( - 'allows Read/Edit/Write/Glob on %s', - (name) => { - expect(wizardCanUseTool('Read', { file_path: `/project/${name}` })).toBe( - true, - ) - expect(wizardCanUseTool('Edit', { file_path: `/project/${name}` })).toBe( - true, - ) - expect( - wizardCanUseTool('Write', { file_path: `/project/${name}` }), - ).toBe(true) - expect(wizardCanUseTool('Glob', { pattern: name })).toBe(true) - }, - ) + it.each([ + '.env.example', + '.env.sample', + '.env.template', + ])('allows Read/Edit/Write/Glob on %s', (name) => { + expect(wizardCanUseTool('Read', { file_path: `/project/${name}` })).toBe( + true, + ) + expect(wizardCanUseTool('Edit', { file_path: `/project/${name}` })).toBe( + true, + ) + expect(wizardCanUseTool('Write', { file_path: `/project/${name}` })).toBe( + true, + ) + expect(wizardCanUseTool('Glob', { pattern: name })).toBe(true) + }) it('still blocks value-bearing files that only start with a template name', () => { expect( diff --git a/turbo.json b/turbo.json index 7a9936878..64780c60d 100644 --- a/turbo.json +++ b/turbo.json @@ -1,59 +1,32 @@ { - "$schema": "https://turbo.build/schema.json", - "tasks": { - "build": { - "dependsOn": [ - "^build" - ], - "inputs": [ - "$TURBO_DEFAULT$", - ".env*" - ] - }, - "release": { - "dependsOn": [ - "^build" - ], - "inputs": [ - "$TURBO_DEFAULT$", - ".env*" - ] - }, - "dev": { - "cache": false, - "persistent": true - }, - "test": { - "dependsOn": [ - "^build" - ], - "inputs": [ - "$TURBO_DEFAULT$", - ".env*" - ], - "cache": false - }, - "test:e2e": { - "dependsOn": [ - "^build", - "build" - ], - "inputs": [ - "$TURBO_DEFAULT$", - ".env*" - ], - "cache": false - }, - "test:integration": { - "dependsOn": [ - "^build", - "build" - ], - "inputs": [ - "$TURBO_DEFAULT$", - ".env*" - ], - "cache": false - } - } + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"] + }, + "release": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "test": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "cache": false + }, + "test:e2e": { + "dependsOn": ["^build", "build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "cache": false + }, + "test:integration": { + "dependsOn": ["^build", "build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "cache": false + } + } }