Skip to content

Commit ebbb143

Browse files
authored
Merge pull request #624 from cipherstash/chore/biome-no-type-erasing-assertions
chore(lint): warn on type-erasing assertions (as any/never/unknown) via Biome plugin
2 parents a2560c2 + 3f4adeb commit ebbb143

18 files changed

Lines changed: 256 additions & 147 deletions

File tree

.github/workflows/tests.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,43 @@ on:
99
- "**"
1010

1111
jobs:
12+
# Biome format + lint. Its own job (no DB, no credentials, node-agnostic) so it
13+
# surfaces as a distinct check and runs once rather than per Node matrix leg.
14+
# Gates on ERRORS only — warnings are allowed, so the `no-type-erasing-assertions`
15+
# plugin (at `warn`) surfaces new `as any`/`never`/`unknown` without blocking.
16+
# Tightening to fail on warnings is tracked in #625.
17+
lint:
18+
name: Lint (Biome)
19+
runs-on: blacksmith-4vcpu-ubuntu-2404
20+
steps:
21+
- name: Checkout Repo
22+
uses: actions/checkout@v6
23+
24+
- uses: pnpm/action-setup@v6.0.8
25+
name: Install pnpm
26+
with:
27+
run_install: false
28+
29+
- name: Install Node.js
30+
uses: actions/setup-node@v6
31+
with:
32+
# Node 22 is the supply-chain hardening baseline every pnpm-using job
33+
# must pin (enforced by e2e/tests/supply-chain.e2e.test.ts). Lint is
34+
# runtime-agnostic, so a single pinned version is fine.
35+
node-version: 22
36+
cache: 'pnpm'
37+
38+
# node-pty's install hook falls back to `node-gyp rebuild` when no
39+
# linux-x64 prebuild matches; pnpm/action-setup v6 no longer ships it.
40+
- name: Install node-gyp
41+
run: npm install -g node-gyp
42+
43+
- name: Install dependencies
44+
run: pnpm install --frozen-lockfile
45+
46+
- name: Biome check (format + lint)
47+
run: pnpm run code:check
48+
1249
run-tests:
1350
name: Run Tests (Node ${{ matrix.node-version }})
1451
runs-on: blacksmith-4vcpu-ubuntu-2404

AGENTS.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,22 @@ Three rules to remember when editing CI or pnpm config:
174174
- **Formatting/Linting**: Use Biome
175175

176176
```bash
177-
pnpm run code:fix
177+
pnpm run code:fix # format + lint, auto-fixing what it can
178+
pnpm run code:check # read-only; this is what CI runs
178179
```
179180

181+
CI runs `code:check` (in `tests.yml`) and gates on **errors** — warnings are
182+
allowed (tracked for tightening). So `code:fix` must leave the tree
183+
error-free before you push.
184+
185+
A Biome GritQL plugin (`biome-plugins/no-type-erasing-assertions.grit`) warns
186+
on `as any` / `as never` / `as unknown` in `src` — type-erasing assertions that
187+
silence the checker instead of narrowing. Fix the type or use a specific
188+
assertion; suppress a deliberate case with `// biome-ignore lint/plugin:
189+
<reason>`. The plugin is scoped to source via an `overrides` entry in
190+
`biome.json` (test/integration files excluded) — see the plugin file's header
191+
for why it must be scoped-in rather than globally-enabled-and-exempted.
192+
180193
- **Build**: `pnpm run build` (Turborepo + tsup per package)
181194
- **Test**: `pnpm --filter <pkg> test` for targeted iterations
182195
- **Releases**: Use Changesets
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Flags type-erasing assertions: `x as any`, `x as never`, `x as unknown`
2+
// (the last also catches the inner half of `x as unknown as T` double-casts).
3+
//
4+
// These punch a hole through the type system rather than narrowing a value, so
5+
// a real type mismatch can hide behind them (e.g. passing the wrong shape to a
6+
// function whose signature you've silenced). Prefer fixing the underlying type,
7+
// narrowing with a guard, or a precise `as SpecificType`.
8+
//
9+
// NOT flagged: `as const` (a widening-freeze, not an erasure), a plain
10+
// `as SpecificType`, or `never` used as a *type annotation* (e.g. a contravariant
11+
// `param: never`) — those are legitimate.
12+
//
13+
// Warning, not error: the repo has a large existing backlog, and this is a
14+
// ratchet to keep NEW erasures visible. Intentional cases take a
15+
// `// biome-ignore lint/plugin: <reason>` suppression.
16+
//
17+
// Wiring note: this plugin is enabled via an `overrides` entry in biome.json
18+
// that INCLUDES source globs and negates test globs — it is NOT enabled at the
19+
// top level. Biome applies top-level `plugins` to every file, and
20+
// `overrides[].plugins: []` does NOT disable them, so "enable globally + exempt
21+
// tests" silently lints tests too. Scoping the plugin in via the override is the
22+
// only way to exempt test doubles (which legitimately force-cast).
23+
or {
24+
`$e as any`,
25+
`$e as never`,
26+
`$e as unknown`
27+
} where {
28+
register_diagnostic(
29+
span = $e,
30+
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: <reason>`.",
31+
severity = "warn"
32+
)
33+
}

biome.json

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,16 @@
66
"!**/package.json",
77
"!**/.turbo/",
88
"!**/dist",
9-
"!**/nix/store"
9+
"!**/nix/store",
10+
"!**/*.grit",
11+
"!**/*.generated.ts",
12+
"!**/contract.json",
13+
"!**/contract.d.ts",
14+
"!**/end-contract.json",
15+
"!**/end-contract.d.ts",
16+
"!**/migrations/**/migration.json",
17+
"!**/migrations/**/ops.json",
18+
"!**/migrations/**/refs"
1019
]
1120
},
1221
"formatter": {
@@ -30,5 +39,20 @@
3039
"noThenProperty": "off"
3140
}
3241
}
33-
}
42+
},
43+
"overrides": [
44+
{
45+
"includes": [
46+
"**/src/**",
47+
"examples/**",
48+
"!**/__tests__/**",
49+
"!**/*.test.ts",
50+
"!**/*.test-d.ts",
51+
"!**/*.spec.ts",
52+
"!**/integration/**",
53+
"!**/tests/**"
54+
],
55+
"plugins": ["./biome-plugins/no-type-erasing-assertions.grit"]
56+
}
57+
]
3458
}

e2e/tests/package-managers.e2e.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ describe('CLI init providers — package-manager-aware Next Steps', () => {
5656
{
5757
label: 'drizzle',
5858
create: createDrizzleProvider,
59-
firstStep: (r) => `Set up your database: ${r} stash eql install --drizzle`,
59+
firstStep: (r) =>
60+
`Set up your database: ${r} stash eql install --drizzle`,
6061
},
6162
{
6263
label: 'supabase',

examples/prisma/migrations/app/20260709T1034_initial/migration.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
#!/usr/bin/env -S node
2-
import { cipherstashAddSearchConfig } from '@prisma-next/extension-cipherstash/migration';
3-
import { Migration, MigrationCLI, col, primaryKey } from '@prisma-next/postgres/migration';
2+
import { cipherstashAddSearchConfig } from '@prisma-next/extension-cipherstash/migration'
3+
import {
4+
col,
5+
Migration,
6+
MigrationCLI,
7+
primaryKey,
8+
} from '@prisma-next/postgres/migration'
49

510
export default class M extends Migration {
611
override describe() {
712
return {
813
from: null,
914
to: 'sha256:b8c4febc9397cf1b68293cdb3b2afe9f568db6967f428e3f207e32575a2bc2fa',
10-
};
15+
}
1116
}
1217

1318
override get operations() {
@@ -34,17 +39,30 @@ export default class M extends Migration {
3439
notNull: true,
3540
codecRef: {
3641
codecId: 'cipherstash/string@1',
37-
typeParams: { equality: true, freeTextSearch: true, orderAndRange: true },
42+
typeParams: {
43+
equality: true,
44+
freeTextSearch: true,
45+
orderAndRange: true,
46+
},
3847
},
3948
}),
4049
col('emailverified', 'eql_v2_encrypted', {
4150
notNull: true,
42-
codecRef: { codecId: 'cipherstash/boolean@1', typeParams: { equality: true } },
51+
codecRef: {
52+
codecId: 'cipherstash/boolean@1',
53+
typeParams: { equality: true },
54+
},
55+
}),
56+
col('id', 'text', {
57+
notNull: true,
58+
codecRef: { codecId: 'pg/text@1' },
4359
}),
44-
col('id', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
4560
col('preferences', 'eql_v2_encrypted', {
4661
notNull: true,
47-
codecRef: { codecId: 'cipherstash/json@1', typeParams: { searchableJson: true } },
62+
codecRef: {
63+
codecId: 'cipherstash/json@1',
64+
typeParams: { searchableJson: true },
65+
},
4866
}),
4967
col('salary', 'eql_v2_encrypted', {
5068
notNull: true,
@@ -80,9 +98,21 @@ export default class M extends Migration {
8098
index: 'ore',
8199
castAs: 'date',
82100
}),
83-
cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'unique' }),
84-
cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'match' }),
85-
cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'ore' }),
101+
cipherstashAddSearchConfig({
102+
table: 'users',
103+
column: 'email',
104+
index: 'unique',
105+
}),
106+
cipherstashAddSearchConfig({
107+
table: 'users',
108+
column: 'email',
109+
index: 'match',
110+
}),
111+
cipherstashAddSearchConfig({
112+
table: 'users',
113+
column: 'email',
114+
index: 'ore',
115+
}),
86116
cipherstashAddSearchConfig({
87117
table: 'users',
88118
column: 'emailverified',
@@ -107,8 +137,8 @@ export default class M extends Migration {
107137
index: 'ore',
108138
castAs: 'double',
109139
}),
110-
];
140+
]
111141
}
112142
}
113143

114-
MigrationCLI.run(import.meta.url, M);
144+
MigrationCLI.run(import.meta.url, M)

examples/prisma/src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ async function main() {
118118
}
119119

120120
async function clearUsers(): Promise<void> {
121-
const removed = await db.orm.public.User.where((u) => u.id.isNotNull()).deleteCount()
121+
const removed = await db.orm.public.User.where((u) =>
122+
u.id.isNotNull(),
123+
).deleteCount()
122124
if (removed > 0) {
123125
console.log(`--- Cleanup ---\nRemoved ${removed} existing user row(s).\n`)
124126
}
@@ -212,7 +214,9 @@ async function equalityQueryOnEmailVerified(): Promise<void> {
212214

213215
async function sortByEmailAsc(): Promise<void> {
214216
console.log('\n--- cipherstashAsc (bare-column ORDER BY) ---')
215-
const rows = await db.orm.public.User.orderBy((u) => cipherstashAsc(u.email)).all()
217+
const rows = await db.orm.public.User.orderBy((u) =>
218+
cipherstashAsc(u.email),
219+
).all()
216220
await decryptAll(rows)
217221
for (const row of rows) {
218222
console.log(` ${row.id}: email=${await row.email.decrypt()}`)

examples/prisma/test/e2e/global-setup.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -119,20 +119,14 @@ export default async function setup(): Promise<() => Promise<void>> {
119119
// is for the `pnpm start` demo loop).
120120
process.env['DATABASE_URL'] = HARNESS_DATABASE_URL
121121

122-
const apply = spawnSync(
123-
'pnpm',
124-
['exec', 'prisma-next', 'migrate', '--yes'],
125-
{
126-
cwd: exampleDir,
127-
stdio: 'pipe',
128-
env: process.env,
129-
timeout: MIGRATION_APPLY_TIMEOUT_MS,
130-
},
131-
)
122+
const apply = spawnSync('pnpm', ['exec', 'prisma-next', 'migrate', '--yes'], {
123+
cwd: exampleDir,
124+
stdio: 'pipe',
125+
env: process.env,
126+
timeout: MIGRATION_APPLY_TIMEOUT_MS,
127+
})
132128
if (apply.error || apply.signal || apply.status !== 0) {
133-
throw new Error(
134-
describeSpawnFailure('`prisma-next migrate`', apply),
135-
)
129+
throw new Error(describeSpawnFailure('`prisma-next migrate`', apply))
136130
}
137131

138132
// Clean slate for the suite. The `users` table is the only data-bearing

examples/prisma/test/e2e/str-range.e2e.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ describe('EncryptedString orderAndRange e2e (live PG + EQL + ZeroKMS)', () => {
7474
})
7575

7676
it('cipherstashAsc orders alphabetically (bare-column ORDER BY on string)', async () => {
77-
const rows = await db.orm.public.User.orderBy((u) => cipherstashAsc(u.email)).all()
77+
const rows = await db.orm.public.User.orderBy((u) =>
78+
cipherstashAsc(u.email),
79+
).all()
7880
expect(rows.map((r) => r.id)).toEqual([
7981
'e2e-str-0',
8082
'e2e-str-1',

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"dev": "turbo dev --filter './packages/*'",
2929
"clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules",
3030
"code:fix": "biome check --write",
31+
"code:check": "biome check",
3132
"lint:runners": "node scripts/lint-no-hardcoded-runners.mjs",
3233
"lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs",
3334
"release": "pnpm run build && changeset publish",

0 commit comments

Comments
 (0)