diff --git a/.agents/skills/migrate-to-shoehorn/SKILL.md b/.agents/skills/migrate-to-shoehorn/SKILL.md new file mode 100644 index 0000000..ae4f965 --- /dev/null +++ b/.agents/skills/migrate-to-shoehorn/SKILL.md @@ -0,0 +1,118 @@ +--- +name: migrate-to-shoehorn +description: Migrate test files from `as` type assertions to @total-typescript/shoehorn. Use when user mentions shoehorn, wants to replace `as` in tests, or needs partial test data. +--- + +# Migrate to Shoehorn + +## Why shoehorn? + +`shoehorn` lets you pass partial data in tests while keeping TypeScript happy. It replaces `as` assertions with type-safe alternatives. + +**Test code only.** Never use shoehorn in production code. + +Problems with `as` in tests: + +- Trained not to use it +- Must manually specify target type +- Double-as (`as unknown as Type`) for intentionally wrong data + +## Install + +```bash +npm i @total-typescript/shoehorn +``` + +## Migration patterns + +### Large objects with few needed properties + +Before: + +```ts +type Request = { + body: { id: string }; + headers: Record; + cookies: Record; + // ...20 more properties +}; + +it("gets user by id", () => { + // Only care about body.id but must fake entire Request + getUser({ + body: { id: "123" }, + headers: {}, + cookies: {}, + // ...fake all 20 properties + }); +}); +``` + +After: + +```ts +import { fromPartial } from "@total-typescript/shoehorn"; + +it("gets user by id", () => { + getUser( + fromPartial({ + body: { id: "123" }, + }), + ); +}); +``` + +### `as Type` → `fromPartial()` + +Before: + +```ts +getUser({ body: { id: "123" } } as Request); +``` + +After: + +```ts +import { fromPartial } from "@total-typescript/shoehorn"; + +getUser(fromPartial({ body: { id: "123" } })); +``` + +### `as unknown as Type` → `fromAny()` + +Before: + +```ts +getUser({ body: { id: 123 } } as unknown as Request); // wrong type on purpose +``` + +After: + +```ts +import { fromAny } from "@total-typescript/shoehorn"; + +getUser(fromAny({ body: { id: 123 } })); +``` + +## When to use each + +| Function | Use case | +| --------------- | -------------------------------------------------- | +| `fromPartial()` | Pass partial data that still type-checks | +| `fromAny()` | Pass intentionally wrong data (keeps autocomplete) | +| `fromExact()` | Force full object (swap with fromPartial later) | + +## Workflow + +1. **Gather requirements** - ask user: + - What test files have `as` assertions causing problems? + - Are they dealing with large objects where only some properties matter? + - Do they need to pass intentionally wrong data for error testing? + +2. **Install and migrate**: + - [ ] Install: `npm i @total-typescript/shoehorn` + - [ ] Find test files with `as` assertions: `grep -r " as [A-Z]" --include="*.test.ts" --include="*.spec.ts"` + - [ ] Replace `as Type` with `fromPartial()` + - [ ] Replace `as unknown as Type` with `fromAny()` + - [ ] Add imports from `@total-typescript/shoehorn` + - [ ] Run type check to verify diff --git a/apps/api/package.json b/apps/api/package.json index 08b3338..08b1f8b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -36,6 +36,7 @@ "@cortex/typescript-config": "workspace:*", "@nestjs/cli": "^11.0.21", "@nestjs/testing": "^11.1.24", + "@total-typescript/shoehorn": "^0.1.2", "@types/express": "^5.0.3", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", diff --git a/apps/api/src/mcp/identity.service.spec.ts b/apps/api/src/mcp/identity.service.spec.ts index fd29ba4..e446381 100644 --- a/apps/api/src/mcp/identity.service.spec.ts +++ b/apps/api/src/mcp/identity.service.spec.ts @@ -1,5 +1,5 @@ +import { fromAny } from "@total-typescript/shoehorn"; import { IdentityService } from "./identity.service"; -import type { PrismaService } from "../prisma/prisma.service"; /** Minimal mock shape for PrismaService covering only what IdentityService uses. */ function makeMockPrisma() { @@ -16,7 +16,7 @@ describe("IdentityService", () => { beforeEach(() => { prisma = makeMockPrisma(); - service = new IdentityService(prisma as unknown as PrismaService); + service = new IdentityService(fromAny(prisma)); }); afterEach(() => { diff --git a/apps/api/src/mcp/mcp.controller.spec.ts b/apps/api/src/mcp/mcp.controller.spec.ts index e4c7f2a..5c3b628 100644 --- a/apps/api/src/mcp/mcp.controller.spec.ts +++ b/apps/api/src/mcp/mcp.controller.spec.ts @@ -1,7 +1,11 @@ +import { fromAny, fromPartial } from "@total-typescript/shoehorn"; +import type { Request } from "express"; import { MCPController } from "./mcp.controller"; import { IdentityService } from "./identity.service"; import type { AuthenticatedUser } from "../auth/auth.types"; +type RequestWithJwtUser = Request & { user: AuthenticatedUser }; + const mockUser: AuthenticatedUser = { id: "user_123", email: "alice@example.com", @@ -9,9 +13,8 @@ const mockUser: AuthenticatedUser = { role: "member", }; -function makeReq(user: AuthenticatedUser = mockUser) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { user } as any; +function makeReq(user: AuthenticatedUser = mockUser): RequestWithJwtUser { + return fromAny({ user }); } describe("MCPController", () => { @@ -19,12 +22,12 @@ describe("MCPController", () => { let identityService: jest.Mocked; beforeEach(() => { - identityService = { + identityService = fromPartial>({ resolveScope: jest.fn().mockResolvedValue({ organizationId: "org_456", departmentId: "dept_789", }), - } as unknown as jest.Mocked; + }); controller = new MCPController(identityService); }); diff --git a/packages/shared/package.json b/packages/shared/package.json index e2b810a..026c60f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,6 +23,7 @@ "license": "ISC", "devDependencies": { "@cortex/typescript-config": "workspace:*", + "@total-typescript/shoehorn": "^0.1.2", "@types/jest": "^30.0.0", "@types/node": "^22.0.0", "jest": "^30.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12f7f47..e5e473d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: '@nestjs/testing': specifier: ^11.1.24 version: 11.1.24(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)) + '@total-typescript/shoehorn': + specifier: ^0.1.2 + version: 0.1.2 '@types/express': specifier: ^5.0.3 version: 5.0.6 @@ -253,6 +256,9 @@ importers: '@cortex/typescript-config': specifier: workspace:* version: link:../typescript-config + '@total-typescript/shoehorn': + specifier: ^0.1.2 + version: 0.1.2 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -1323,6 +1329,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@total-typescript/shoehorn@0.1.2': + resolution: {integrity: sha512-p7nNZbOZIofpDNyP0u1BctFbjxD44Qc+oO5jufgQdFdGIXJLc33QRloJpq7k5T59CTgLWfQSUxsuqLcmeurYRw==} + '@turbo/darwin-64@2.9.14': resolution: {integrity: sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==} cpu: [x64] @@ -5482,6 +5491,8 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@total-typescript/shoehorn@0.1.2': {} + '@turbo/darwin-64@2.9.14': optional: true diff --git a/skills-lock.json b/skills-lock.json index dd0fea2..02f85a7 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,12 @@ { "version": 1, "skills": { + "migrate-to-shoehorn": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/migrate-to-shoehorn/SKILL.md", + "computedHash": "67fdd18f8f4f7c89b3003eb944220679272738c1deb680719fd2e282495d87f4" + }, "next-best-practices": { "source": "vercel-labs/next-skills", "sourceType": "github",