@fitzzero/quickdraw-core 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +113 -64
  2. package/dist/client/index.js +32 -61
  3. package/dist/client/index.js.map +1 -1
  4. package/dist/client/testing.js.map +1 -1
  5. package/dist/server/express/index.d.ts +37 -0
  6. package/dist/server/express/index.js +60 -0
  7. package/dist/server/express/index.js.map +1 -0
  8. package/dist/server/index.d.ts +227 -1
  9. package/dist/server/index.js +400 -142
  10. package/dist/server/index.js.map +1 -1
  11. package/dist/server/testing-prisma.d.ts +101 -0
  12. package/dist/server/testing-prisma.js +172 -0
  13. package/dist/server/testing-prisma.js.map +1 -0
  14. package/dist/server/testing.js +5 -22
  15. package/dist/server/testing.js.map +1 -1
  16. package/dist/shared/index.js.map +1 -1
  17. package/eslint-config/index.mjs +1 -2
  18. package/eslint-plugin-quickdraw/index.mjs +15 -7
  19. package/eslint-plugin-quickdraw/rules/no-cross-service-mutations.mjs +31 -32
  20. package/eslint-plugin-quickdraw/rules/no-direct-prisma-mutations.mjs +17 -18
  21. package/eslint-plugin-quickdraw/rules/no-raw-button-strings.mjs +52 -0
  22. package/eslint-plugin-quickdraw/rules/no-raw-service-room-string.mjs +140 -0
  23. package/eslint-plugin-quickdraw/rules/no-raw-socket-emit.mjs +20 -22
  24. package/eslint-plugin-quickdraw/rules/no-raw-socket-on.mjs +17 -17
  25. package/eslint-plugin-quickdraw/rules/no-raw-tooltip-strings.mjs +59 -0
  26. package/eslint-plugin-quickdraw/rules/no-raw-typography-strings.mjs +52 -0
  27. package/eslint-plugin-quickdraw/rules/no-service-method-record.mjs +3 -3
  28. package/eslint-plugin-quickdraw/rules/no-unsafe-payload-cast.mjs +8 -8
  29. package/eslint-plugin-quickdraw/rules/require-zod-schema.mjs +15 -20
  30. package/package.json +74 -50
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shared/types.ts"],"names":[],"mappings":";AAkTO,IAAM,aAAA,GAAwB;AAAA,EACnC,IAAA,EAAM,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,IAAI,CAAA,OAAA,EAAU,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACpE,IAAA,EAAM,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,KAAK,CAAA,OAAA,EAAU,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACrE,KAAA,EAAO,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,MAAM,CAAA,QAAA,EAAW,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACxE,KAAA,EAAO,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,MAAM,CAAA,QAAA,EAAW,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACxE,OAAO,MAAM;AACf","file":"index.js","sourcesContent":["import type { z } from \"zod\";\n\n// ============================================================================\n// Access Control Types\n// ============================================================================\n\nexport type AccessLevel = \"Public\" | \"Read\" | \"Moderate\" | \"Admin\";\n\nexport type ACE = {\n userId: string;\n level: AccessLevel;\n};\n\nexport type ACL = ACE[];\n\n// ============================================================================\n// Entity and User Interfaces for ACL\n// ============================================================================\n\n/**\n * Interface for entities that support entry-level ACL stored as JSON.\n *\n * Use this pattern when:\n * - You want simple ACL without a separate membership table\n * - Access patterns are straightforward (read/write permissions)\n * - You don't need to query \"all entities user X can access\" efficiently\n *\n * The default `checkEntryACL` in BaseService expects entities to have this shape.\n *\n * @example\n * ```prisma\n * model Document {\n * id String @id @default(cuid())\n * title String\n * acl Json? // Stores ACL array\n * }\n * ```\n *\n * @example\n * ```typescript\n * class DocumentService extends BaseService<Document & ACLEntity, ...> {\n * constructor(prisma: PrismaClient) {\n * super({ serviceName: 'documentService', hasEntryACL: true });\n * this.setDelegate(prisma.document);\n * // Default checkEntryACL will work automatically\n * }\n * }\n * ```\n */\nexport interface ACLEntity {\n id: string;\n acl?: ACL | null;\n}\n\n/**\n * Interface for users with service-level access control.\n *\n * The `serviceAccess` field maps service names to access levels, granting\n * blanket permissions across all entries in that service.\n *\n * @example\n * ```prisma\n * model User {\n * id String @id @default(cuid())\n * serviceAccess Json? // { \"chatService\": \"Admin\", \"documentService\": \"Read\" }\n * }\n * ```\n *\n * @example\n * ```typescript\n * // User with Admin access to chatService can perform any operation\n * // on any chat, regardless of entry-level ACL\n * const user: QuickdrawUser = {\n * id: \"user-123\",\n * serviceAccess: { chatService: \"Admin\" }\n * };\n * ```\n */\nexport interface QuickdrawUser {\n id: string;\n serviceAccess?: Record<string, AccessLevel> | null;\n}\n\n// ============================================================================\n// Service Response Types\n// ============================================================================\n\nexport type ServiceResponse<T = unknown> =\n | { success: true; data: T }\n | { success: false; error: string; code?: number };\n\n// ============================================================================\n// Service Method Definition Types\n// ============================================================================\n\n/**\n * Defines the shape of a service method including its payload and response types.\n * Used to create a type-safe contract between server and client.\n */\nexport interface ServiceMethodDefinition<\n TPayload = unknown,\n TResponse = unknown,\n> {\n name: string;\n access: AccessLevel;\n handler: (\n payload: TPayload,\n context: ServiceMethodContext\n ) => Promise<TResponse>;\n schema?: z.ZodType<TPayload>;\n resolveEntryId?: (payload: TPayload) => string | null;\n}\n\n/**\n * Context provided to service method handlers\n */\nexport interface ServiceMethodContext {\n userId: string | undefined;\n socketId: string;\n serviceAccess: Record<string, AccessLevel>;\n}\n\n/**\n * Type helper for defining service method maps.\n * Each service should define its methods using this structure.\n *\n * @example\n * ```typescript\n * type ChatServiceMethods = ServiceMethodMap<{\n * createChat: {\n * payload: { title: string };\n * response: { id: string };\n * };\n * updateTitle: {\n * payload: { id: string; title: string };\n * response: { id: string; title: string };\n * };\n * }>;\n * ```\n */\nexport type ServiceMethodMap<\n T extends Record<string, { payload: unknown; response: unknown }>,\n> = T;\n\n// ============================================================================\n// Admin Method Types (Generic shapes for all services)\n// ============================================================================\n\nexport type AdminListPayload = {\n page?: number;\n pageSize?: number;\n sort?: {\n field?: string;\n direction?: \"asc\" | \"desc\";\n };\n filter?: {\n id?: string;\n ids?: string[];\n createdAfter?: string;\n createdBefore?: string;\n updatedAfter?: string;\n updatedBefore?: string;\n } & Record<string, unknown>;\n};\n\nexport type AdminListResponse<T> = {\n rows: T[];\n page: number;\n pageSize: number;\n total: number;\n};\n\nexport type AdminGetPayload = { id: string };\n\nexport type AdminCreatePayload<TInsert> = { data: Partial<TInsert> };\n\nexport type AdminUpdatePayload<TInsert> = {\n id: string;\n data: Partial<TInsert>;\n};\n\nexport type AdminDeletePayload = { id: string };\nexport type AdminDeleteResponse = { id: string; deleted: true };\n\nexport type AdminSetEntryACLPayload = {\n id: string;\n acl: ACL;\n};\n\nexport type AdminGetSubscribersPayload = { id: string };\nexport type AdminGetSubscribersResponse = {\n id: string;\n subscribers: Array<{ socketId: string; userId?: string }>;\n};\n\nexport type AdminReemitPayload = { id: string };\nexport type AdminReemitResponse = { emitted: boolean };\n\nexport type AdminUnsubscribeAllPayload = { id: string };\nexport type AdminUnsubscribeAllResponse = { id: string; unsubscribed: number };\n\n// ============================================================================\n// Admin Field Configuration Types\n// ============================================================================\n\n/**\n * Supported field types for admin UI generation.\n */\nexport type AdminFieldType =\n | \"string\"\n | \"number\"\n | \"boolean\"\n | \"date\"\n | \"enum\"\n | \"json\"\n | \"relation\";\n\n/**\n * Configuration for a single field in the admin UI.\n * Derived from Zod schemas with optional overrides.\n */\nexport interface AdminFieldConfig {\n /** Field name (property key on the entity) */\n name: string;\n /** Field type for rendering appropriate input */\n type: AdminFieldType;\n /** Human-readable label */\n label: string;\n /** Whether the field is required for creation */\n required: boolean;\n /** Whether the field can be edited (false for id, createdAt, etc.) */\n editable: boolean;\n /** Whether to show this field as a table column */\n showInTable: boolean;\n /** Whether the table can be sorted by this field */\n sortable: boolean;\n /** For enum fields, the allowed values */\n enumValues?: string[];\n /** For relation fields, the related service name */\n relationService?: string;\n}\n\n/**\n * Service metadata for admin UI generation.\n * Returned by the adminMeta method.\n */\nexport interface AdminServiceMeta {\n /** Internal service name (e.g., \"chatService\") */\n serviceName: string;\n /** Human-readable display name (e.g., \"Chats\") */\n displayName: string;\n /** Field configurations derived from schema */\n fields: AdminFieldConfig[];\n}\n\n/**\n * Payload for adminMeta method (no parameters needed)\n */\nexport type AdminMetaPayload = Record<string, never>;\n\n/**\n * Response type for adminMeta method\n */\nexport type AdminMetaResponse = AdminServiceMeta;\n\n// ============================================================================\n// Subscription Types\n// ============================================================================\n\nexport type SubscribePayload = {\n entryId: string;\n requiredLevel?: AccessLevel;\n};\n\nexport type UnsubscribePayload = {\n entryId: string;\n};\n\n// ============================================================================\n// Utility Types\n// ============================================================================\n\n/**\n * Extracts payload type from a service method definition\n */\nexport type ExtractPayload<T> = T extends { payload: infer P } ? P : never;\n\n/**\n * Extracts response type from a service method definition\n */\nexport type ExtractResponse<T> = T extends { response: infer R } ? R : never;\n\n/**\n * Logger interface compatible with Winston and other loggers\n */\nexport interface Logger {\n info(message: string, meta?: Record<string, unknown>): void;\n warn(message: string, meta?: Record<string, unknown>): void;\n error(message: string, meta?: Record<string, unknown>): void;\n debug(message: string, meta?: Record<string, unknown>): void;\n child(options: Record<string, unknown>): Logger;\n}\n\n/**\n * Default console logger implementation\n */\nexport const consoleLogger: Logger = {\n info: (message, meta) => console.log(`[INFO] ${message}`, meta ?? \"\"),\n warn: (message, meta) => console.warn(`[WARN] ${message}`, meta ?? \"\"),\n error: (message, meta) => console.error(`[ERROR] ${message}`, meta ?? \"\"),\n debug: (message, meta) => console.debug(`[DEBUG] ${message}`, meta ?? \"\"),\n child: () => consoleLogger,\n};\n"]}
1
+ {"version":3,"sources":["../../src/shared/types.ts"],"names":[],"mappings":";AA0SO,IAAM,aAAA,GAAwB;AAAA,EACnC,IAAA,EAAM,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,IAAI,CAAA,OAAA,EAAU,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACpE,IAAA,EAAM,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,KAAK,CAAA,OAAA,EAAU,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACrE,KAAA,EAAO,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,MAAM,CAAA,QAAA,EAAW,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACxE,KAAA,EAAO,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,MAAM,CAAA,QAAA,EAAW,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACxE,OAAO,MAAM;AACf","file":"index.js","sourcesContent":["import type { z } from \"zod\";\n\n// ============================================================================\n// Access Control Types\n// ============================================================================\n\nexport type AccessLevel = \"Public\" | \"Read\" | \"Moderate\" | \"Admin\";\n\nexport type ACE = {\n userId: string;\n level: AccessLevel;\n};\n\nexport type ACL = ACE[];\n\n// ============================================================================\n// Entity and User Interfaces for ACL\n// ============================================================================\n\n/**\n * Interface for entities that support entry-level ACL stored as JSON.\n *\n * Use this pattern when:\n * - You want simple ACL without a separate membership table\n * - Access patterns are straightforward (read/write permissions)\n * - You don't need to query \"all entities user X can access\" efficiently\n *\n * The default `checkEntryACL` in BaseService expects entities to have this shape.\n *\n * @example\n * ```prisma\n * model Document {\n * id String @id @default(cuid())\n * title String\n * acl Json? // Stores ACL array\n * }\n * ```\n *\n * @example\n * ```typescript\n * class DocumentService extends BaseService<Document & ACLEntity, ...> {\n * constructor(prisma: PrismaClient) {\n * super({ serviceName: 'documentService', hasEntryACL: true });\n * this.setDelegate(prisma.document);\n * // Default checkEntryACL will work automatically\n * }\n * }\n * ```\n */\nexport interface ACLEntity {\n id: string;\n acl?: ACL | null;\n}\n\n/**\n * Interface for users with service-level access control.\n *\n * The `serviceAccess` field maps service names to access levels, granting\n * blanket permissions across all entries in that service.\n *\n * @example\n * ```prisma\n * model User {\n * id String @id @default(cuid())\n * serviceAccess Json? // { \"chatService\": \"Admin\", \"documentService\": \"Read\" }\n * }\n * ```\n *\n * @example\n * ```typescript\n * // User with Admin access to chatService can perform any operation\n * // on any chat, regardless of entry-level ACL\n * const user: QuickdrawUser = {\n * id: \"user-123\",\n * serviceAccess: { chatService: \"Admin\" }\n * };\n * ```\n */\nexport interface QuickdrawUser {\n id: string;\n serviceAccess?: Record<string, AccessLevel> | null;\n}\n\n// ============================================================================\n// Service Response Types\n// ============================================================================\n\nexport type ServiceResponse<T = unknown> =\n | { success: true; data: T }\n | { success: false; error: string; code?: number };\n\n// ============================================================================\n// Service Method Definition Types\n// ============================================================================\n\n/**\n * Defines the shape of a service method including its payload and response types.\n * Used to create a type-safe contract between server and client.\n */\nexport interface ServiceMethodDefinition<TPayload = unknown, TResponse = unknown> {\n name: string;\n access: AccessLevel;\n handler: (payload: TPayload, context: ServiceMethodContext) => Promise<TResponse>;\n schema?: z.ZodType<TPayload>;\n resolveEntryId?: (payload: TPayload) => string | null;\n}\n\n/**\n * Context provided to service method handlers\n */\nexport interface ServiceMethodContext {\n userId: string | undefined;\n socketId: string;\n serviceAccess: Record<string, AccessLevel>;\n}\n\n/**\n * Type helper for defining service method maps.\n * Each service should define its methods using this structure.\n *\n * @example\n * ```typescript\n * type ChatServiceMethods = ServiceMethodMap<{\n * createChat: {\n * payload: { title: string };\n * response: { id: string };\n * };\n * updateTitle: {\n * payload: { id: string; title: string };\n * response: { id: string; title: string };\n * };\n * }>;\n * ```\n */\nexport type ServiceMethodMap<T extends Record<string, { payload: unknown; response: unknown }>> = T;\n\n// ============================================================================\n// Admin Method Types (Generic shapes for all services)\n// ============================================================================\n\nexport type AdminListPayload = {\n page?: number;\n pageSize?: number;\n sort?: {\n field?: string;\n direction?: \"asc\" | \"desc\";\n };\n filter?: {\n id?: string;\n ids?: string[];\n createdAfter?: string;\n createdBefore?: string;\n updatedAfter?: string;\n updatedBefore?: string;\n } & Record<string, unknown>;\n};\n\nexport type AdminListResponse<T> = {\n rows: T[];\n page: number;\n pageSize: number;\n total: number;\n};\n\nexport type AdminGetPayload = { id: string };\n\nexport type AdminCreatePayload<TInsert> = { data: Partial<TInsert> };\n\nexport type AdminUpdatePayload<TInsert> = {\n id: string;\n data: Partial<TInsert>;\n};\n\nexport type AdminDeletePayload = { id: string };\nexport type AdminDeleteResponse = { id: string; deleted: true };\n\nexport type AdminSetEntryACLPayload = {\n id: string;\n acl: ACL;\n};\n\nexport type AdminGetSubscribersPayload = { id: string };\nexport type AdminGetSubscribersResponse = {\n id: string;\n subscribers: Array<{ socketId: string; userId?: string }>;\n};\n\nexport type AdminReemitPayload = { id: string };\nexport type AdminReemitResponse = { emitted: boolean };\n\nexport type AdminUnsubscribeAllPayload = { id: string };\nexport type AdminUnsubscribeAllResponse = { id: string; unsubscribed: number };\n\n// ============================================================================\n// Admin Field Configuration Types\n// ============================================================================\n\n/**\n * Supported field types for admin UI generation.\n */\nexport type AdminFieldType =\n | \"string\"\n | \"number\"\n | \"boolean\"\n | \"date\"\n | \"enum\"\n | \"json\"\n | \"relation\";\n\n/**\n * Configuration for a single field in the admin UI.\n * Derived from Zod schemas with optional overrides.\n */\nexport interface AdminFieldConfig {\n /** Field name (property key on the entity) */\n name: string;\n /** Field type for rendering appropriate input */\n type: AdminFieldType;\n /** Human-readable label */\n label: string;\n /** Whether the field is required for creation */\n required: boolean;\n /** Whether the field can be edited (false for id, createdAt, etc.) */\n editable: boolean;\n /** Whether to show this field as a table column */\n showInTable: boolean;\n /** Whether the table can be sorted by this field */\n sortable: boolean;\n /** For enum fields, the allowed values */\n enumValues?: string[];\n /** For relation fields, the related service name */\n relationService?: string;\n}\n\n/**\n * Service metadata for admin UI generation.\n * Returned by the adminMeta method.\n */\nexport interface AdminServiceMeta {\n /** Internal service name (e.g., \"chatService\") */\n serviceName: string;\n /** Human-readable display name (e.g., \"Chats\") */\n displayName: string;\n /** Field configurations derived from schema */\n fields: AdminFieldConfig[];\n}\n\n/**\n * Payload for adminMeta method (no parameters needed)\n */\nexport type AdminMetaPayload = Record<string, never>;\n\n/**\n * Response type for adminMeta method\n */\nexport type AdminMetaResponse = AdminServiceMeta;\n\n// ============================================================================\n// Subscription Types\n// ============================================================================\n\nexport type SubscribePayload = {\n entryId: string;\n requiredLevel?: AccessLevel;\n};\n\nexport type UnsubscribePayload = {\n entryId: string;\n};\n\n// ============================================================================\n// Utility Types\n// ============================================================================\n\n/**\n * Extracts payload type from a service method definition\n */\nexport type ExtractPayload<T> = T extends { payload: infer P } ? P : never;\n\n/**\n * Extracts response type from a service method definition\n */\nexport type ExtractResponse<T> = T extends { response: infer R } ? R : never;\n\n/**\n * Logger interface compatible with Winston and other loggers\n */\nexport interface Logger {\n info(message: string, meta?: Record<string, unknown>): void;\n warn(message: string, meta?: Record<string, unknown>): void;\n error(message: string, meta?: Record<string, unknown>): void;\n debug(message: string, meta?: Record<string, unknown>): void;\n child(options: Record<string, unknown>): Logger;\n}\n\n/**\n * Default console logger implementation\n */\nexport const consoleLogger: Logger = {\n info: (message, meta) => console.log(`[INFO] ${message}`, meta ?? \"\"),\n warn: (message, meta) => console.warn(`[WARN] ${message}`, meta ?? \"\"),\n error: (message, meta) => console.error(`[ERROR] ${message}`, meta ?? \"\"),\n debug: (message, meta) => console.debug(`[DEBUG] ${message}`, meta ?? \"\"),\n child: () => consoleLogger,\n};\n"]}
@@ -71,8 +71,7 @@ export const base = [
71
71
  "no-restricted-syntax": [
72
72
  "error",
73
73
  {
74
- selector:
75
- 'TSAsExpression[typeAnnotation.typeName.name="unknown"] > TSAsExpression',
74
+ selector: 'TSAsExpression[typeAnnotation.typeName.name="unknown"] > TSAsExpression',
76
75
  message:
77
76
  "Avoid double `as unknown as` casts. Use proper generics, type guards, or extend the type system.",
78
77
  },
@@ -1,9 +1,13 @@
1
- import noCrossServiceMutations from "./rules/no-cross-service-mutations.mjs"
2
- import requireZodSchema from "./rules/require-zod-schema.mjs"
3
- import noServiceMethodRecord from "./rules/no-service-method-record.mjs"
4
- import noUnsafePayloadCast from "./rules/no-unsafe-payload-cast.mjs"
5
- import noRawSocketOn from "./rules/no-raw-socket-on.mjs"
6
- import noRawSocketEmit from "./rules/no-raw-socket-emit.mjs"
1
+ import noCrossServiceMutations from "./rules/no-cross-service-mutations.mjs";
2
+ import requireZodSchema from "./rules/require-zod-schema.mjs";
3
+ import noServiceMethodRecord from "./rules/no-service-method-record.mjs";
4
+ import noUnsafePayloadCast from "./rules/no-unsafe-payload-cast.mjs";
5
+ import noRawSocketOn from "./rules/no-raw-socket-on.mjs";
6
+ import noRawSocketEmit from "./rules/no-raw-socket-emit.mjs";
7
+ import noRawServiceRoomString from "./rules/no-raw-service-room-string.mjs";
8
+ import noRawButtonStrings from "./rules/no-raw-button-strings.mjs";
9
+ import noRawTooltipStrings from "./rules/no-raw-tooltip-strings.mjs";
10
+ import noRawTypographyStrings from "./rules/no-raw-typography-strings.mjs";
7
11
 
8
12
  /** @type {import('eslint').ESLint.Plugin} */
9
13
  export default {
@@ -14,5 +18,9 @@ export default {
14
18
  "no-unsafe-payload-cast": noUnsafePayloadCast,
15
19
  "no-raw-socket-on": noRawSocketOn,
16
20
  "no-raw-socket-emit": noRawSocketEmit,
21
+ "no-raw-service-room-string": noRawServiceRoomString,
22
+ "no-raw-button-strings": noRawButtonStrings,
23
+ "no-raw-tooltip-strings": noRawTooltipStrings,
24
+ "no-raw-typography-strings": noRawTypographyStrings,
17
25
  },
18
- }
26
+ };
@@ -1,4 +1,4 @@
1
- const MUTATION_METHODS = new Set(["create", "update", "delete"])
1
+ const MUTATION_METHODS = new Set(["create", "update", "delete"]);
2
2
 
3
3
  /**
4
4
  * Extract the service directory name from a file path.
@@ -6,8 +6,8 @@ const MUTATION_METHODS = new Set(["create", "update", "delete"])
6
6
  * "/app/services/task/helpers.ts" → "task"
7
7
  */
8
8
  function getServiceDirName(filename) {
9
- const match = filename.match(/services\/([^/]+)\//)
10
- return match ? match[1] : null
9
+ const match = filename.match(/services\/([^/]+)\//);
10
+ return match ? match[1] : null;
11
11
  }
12
12
 
13
13
  /**
@@ -16,7 +16,7 @@ function getServiceDirName(filename) {
16
16
  * e.g. "cloud-build" → "cloudBuild", "task" → "task"
17
17
  */
18
18
  function dirNameToModelName(dirName) {
19
- return dirName.replace(/[-_](\w)/g, (_, c) => c.toUpperCase())
19
+ return dirName.replace(/[-_](\w)/g, (_, c) => c.toUpperCase());
20
20
  }
21
21
 
22
22
  /** @type {import('eslint').Rule.RuleModule} */
@@ -56,38 +56,37 @@ export default {
56
56
  ],
57
57
  },
58
58
  create(context) {
59
- const filename = context.filename || context.getFilename()
60
- const serviceDirName = getServiceDirName(filename)
59
+ const filename = context.filename || context.getFilename();
60
+ const serviceDirName = getServiceDirName(filename);
61
61
 
62
- if (!serviceDirName) return {}
62
+ if (!serviceDirName) return {};
63
63
 
64
- const ownedModel = dirNameToModelName(serviceDirName)
65
- const isIndexFile = filename.endsWith("/index.ts") || filename.endsWith("/index.js")
66
- const options = context.options[0] || {}
67
- const allowedModels = options.allowedModels || {}
68
- const allowedForThisService = new Set(allowedModels[serviceDirName] || [])
64
+ const ownedModel = dirNameToModelName(serviceDirName);
65
+ const isIndexFile = filename.endsWith("/index.ts") || filename.endsWith("/index.js");
66
+ const options = context.options[0] || {};
67
+ const allowedModels = options.allowedModels || {};
68
+ const allowedForThisService = new Set(allowedModels[serviceDirName] || []);
69
69
 
70
70
  return {
71
71
  CallExpression(node) {
72
- const callee = node.callee
73
- if (callee.type !== "MemberExpression") return
74
- if (callee.property.type !== "Identifier") return
72
+ const callee = node.callee;
73
+ if (callee.type !== "MemberExpression") return;
74
+ if (callee.property.type !== "Identifier") return;
75
75
 
76
- const methodName = callee.property.name
77
- if (!MUTATION_METHODS.has(methodName)) return
76
+ const methodName = callee.property.name;
77
+ if (!MUTATION_METHODS.has(methodName)) return;
78
78
 
79
- const obj = callee.object
80
- if (obj.type !== "MemberExpression") return
79
+ const obj = callee.object;
80
+ if (obj.type !== "MemberExpression") return;
81
81
 
82
- const parentObj = obj.object
83
- if (parentObj.type !== "MemberExpression") return
84
- if (parentObj.property.type !== "Identifier") return
85
- if (parentObj.property.name !== "prisma") return
82
+ const parentObj = obj.object;
83
+ if (parentObj.type !== "MemberExpression") return;
84
+ if (parentObj.property.type !== "Identifier") return;
85
+ if (parentObj.property.name !== "prisma") return;
86
86
 
87
- if (parentObj.object.type !== "ThisExpression") return
87
+ if (parentObj.object.type !== "ThisExpression") return;
88
88
 
89
- const modelName =
90
- obj.property.type === "Identifier" ? obj.property.name : "model"
89
+ const modelName = obj.property.type === "Identifier" ? obj.property.name : "model";
91
90
 
92
91
  if (modelName === ownedModel) {
93
92
  if (!isIndexFile) {
@@ -95,19 +94,19 @@ export default {
95
94
  node,
96
95
  messageId: "useBaseService",
97
96
  data: { method: methodName, model: modelName },
98
- })
97
+ });
99
98
  }
100
- return
99
+ return;
101
100
  }
102
101
 
103
- if (allowedForThisService.has(modelName)) return
102
+ if (allowedForThisService.has(modelName)) return;
104
103
 
105
104
  context.report({
106
105
  node,
107
106
  messageId: "crossServiceMutation",
108
107
  data: { method: methodName, model: modelName },
109
- })
108
+ });
110
109
  },
111
- }
110
+ };
112
111
  },
113
- }
112
+ };
@@ -14,36 +14,35 @@ export default {
14
14
  schema: [],
15
15
  },
16
16
  create(context) {
17
- const MUTATION_METHODS = new Set(["create", "update", "delete"])
17
+ const MUTATION_METHODS = new Set(["create", "update", "delete"]);
18
18
 
19
19
  return {
20
20
  CallExpression(node) {
21
- const callee = node.callee
22
- if (callee.type !== "MemberExpression") return
23
- if (callee.property.type !== "Identifier") return
21
+ const callee = node.callee;
22
+ if (callee.type !== "MemberExpression") return;
23
+ if (callee.property.type !== "Identifier") return;
24
24
 
25
- const methodName = callee.property.name
26
- if (!MUTATION_METHODS.has(methodName)) return
25
+ const methodName = callee.property.name;
26
+ if (!MUTATION_METHODS.has(methodName)) return;
27
27
 
28
- const obj = callee.object
29
- if (obj.type !== "MemberExpression") return
28
+ const obj = callee.object;
29
+ if (obj.type !== "MemberExpression") return;
30
30
 
31
- const parentObj = obj.object
32
- if (parentObj.type !== "MemberExpression") return
33
- if (parentObj.property.type !== "Identifier") return
34
- if (parentObj.property.name !== "prisma") return
31
+ const parentObj = obj.object;
32
+ if (parentObj.type !== "MemberExpression") return;
33
+ if (parentObj.property.type !== "Identifier") return;
34
+ if (parentObj.property.name !== "prisma") return;
35
35
 
36
- if (parentObj.object.type !== "ThisExpression") return
36
+ if (parentObj.object.type !== "ThisExpression") return;
37
37
 
38
- const modelName =
39
- obj.property.type === "Identifier" ? obj.property.name : "model"
38
+ const modelName = obj.property.type === "Identifier" ? obj.property.name : "model";
40
39
 
41
40
  context.report({
42
41
  node,
43
42
  messageId: "noDirectMutation",
44
43
  data: { method: methodName, model: modelName },
45
- })
44
+ });
46
45
  },
47
- }
46
+ };
48
47
  },
49
- }
48
+ };
@@ -0,0 +1,52 @@
1
+ /** @type {import('eslint').Rule.RuleModule} */
2
+ export default {
3
+ meta: {
4
+ type: "problem",
5
+ docs: {
6
+ description:
7
+ "Disallow raw string literals in Button components. Use useTranslations() or the <T> component for localization.",
8
+ },
9
+ messages: {
10
+ noRawButtonStrings:
11
+ "Raw string '{{content}}' in Button component should be localized. Use t('key') or <T>key</T> instead.",
12
+ },
13
+ schema: [],
14
+ },
15
+ create(context) {
16
+ return {
17
+ JSXElement(node) {
18
+ // Check if this is a Button element
19
+ if (
20
+ node.openingElement.name.type !== "JSXIdentifier" ||
21
+ node.openingElement.name.name !== "Button"
22
+ ) {
23
+ return;
24
+ }
25
+
26
+ // Check all children for raw strings
27
+ for (const child of node.children) {
28
+ if (child.type === "JSXText") {
29
+ // Flag JSXText that contains non-whitespace characters
30
+ const trimmedText = child.value.trim();
31
+ if (trimmedText.length > 0) {
32
+ context.report({
33
+ node: child,
34
+ messageId: "noRawButtonStrings",
35
+ data: { content: trimmedText },
36
+ });
37
+ }
38
+ } else if (child.type === "JSXExpressionContainer") {
39
+ // Flag JSXExpressionContainer where the expression is a string literal
40
+ if (child.expression.type === "Literal" && typeof child.expression.value === "string") {
41
+ context.report({
42
+ node: child,
43
+ messageId: "noRawButtonStrings",
44
+ data: { content: child.expression.value },
45
+ });
46
+ }
47
+ }
48
+ }
49
+ },
50
+ };
51
+ },
52
+ };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Bans raw template literals used as room name strings in emit/to/join calls.
3
+ * Use serviceRoom(), userRoom(), etc. helpers from your shared package instead.
4
+ *
5
+ * Options: { additionalPatterns: string[] } — app-specific room patterns
6
+ * (regex sources) checked in addition to the defaults.
7
+ */
8
+
9
+ const DEFAULT_ROOM_PATTERNS = [/Service:\$\{/, /^user:\$\{/];
10
+
11
+ /**
12
+ * Check if a template literal node looks like a room string.
13
+ */
14
+ function isRoomTemplateLiteral(node, patterns) {
15
+ if (node.type !== "TemplateLiteral" || node.expressions.length === 0) {
16
+ return false;
17
+ }
18
+
19
+ // Reconstruct the raw template pattern by replacing expressions with ${...}
20
+ let raw = "";
21
+ for (let i = 0; i < node.quasis.length; i++) {
22
+ raw += node.quasis[i].value.raw;
23
+ if (i < node.expressions.length) {
24
+ raw += "${...}";
25
+ }
26
+ }
27
+
28
+ return patterns.some((pattern) => pattern.test(raw));
29
+ }
30
+
31
+ /**
32
+ * Check if a node is a call to emitToRoom, .to(), .join(), or
33
+ * emitConnectedUsers and return the argument index that represents the room name.
34
+ */
35
+ function getRoomArgIndex(node) {
36
+ if (node.type !== "CallExpression") return -1;
37
+
38
+ const callee = node.callee;
39
+
40
+ // this.emitToRoom(ROOM, ...) or service.emitToRoom(ROOM, ...)
41
+ if (
42
+ callee.type === "MemberExpression" &&
43
+ callee.property.type === "Identifier" &&
44
+ callee.property.name === "emitToRoom"
45
+ ) {
46
+ return 0;
47
+ }
48
+
49
+ // .to(ROOM) — Socket.IO chaining
50
+ if (
51
+ callee.type === "MemberExpression" &&
52
+ callee.property.type === "Identifier" &&
53
+ callee.property.name === "to"
54
+ ) {
55
+ return 0;
56
+ }
57
+
58
+ // .join(ROOM) — Socket.IO room joining
59
+ if (
60
+ callee.type === "MemberExpression" &&
61
+ callee.property.type === "Identifier" &&
62
+ callee.property.name === "join"
63
+ ) {
64
+ return 0;
65
+ }
66
+
67
+ // this.emitConnectedUsers(entityId, ROOM)
68
+ if (
69
+ callee.type === "MemberExpression" &&
70
+ callee.property.type === "Identifier" &&
71
+ callee.property.name === "emitConnectedUsers"
72
+ ) {
73
+ return 1;
74
+ }
75
+
76
+ return -1;
77
+ }
78
+
79
+ /** @type {import('eslint').Rule.RuleModule} */
80
+ export default {
81
+ meta: {
82
+ type: "problem",
83
+ docs: {
84
+ description:
85
+ "Disallow raw template literal room strings in emit/to/join calls. Use serviceRoom() or other room helpers from your shared package.",
86
+ },
87
+ messages: {
88
+ noRawRoomString:
89
+ "Use serviceRoom() or other room helpers from your shared package instead of raw template literals for room names.",
90
+ },
91
+ schema: [
92
+ {
93
+ type: "object",
94
+ properties: {
95
+ additionalPatterns: {
96
+ type: "array",
97
+ items: { type: "string" },
98
+ },
99
+ },
100
+ additionalProperties: false,
101
+ },
102
+ ],
103
+ },
104
+ create(context) {
105
+ const options = context.options[0] ?? {};
106
+ const patterns = [
107
+ ...DEFAULT_ROOM_PATTERNS,
108
+ ...(options.additionalPatterns ?? []).map((source) => new RegExp(source)),
109
+ ];
110
+
111
+ return {
112
+ CallExpression(node) {
113
+ const argIndex = getRoomArgIndex(node);
114
+ if (argIndex < 0 || !node.arguments[argIndex]) return;
115
+
116
+ const arg = node.arguments[argIndex];
117
+ if (isRoomTemplateLiteral(arg, patterns)) {
118
+ context.report({
119
+ node: arg,
120
+ messageId: "noRawRoomString",
121
+ });
122
+ }
123
+ },
124
+ // Also catch: const room = `chatService:${id}`;
125
+ VariableDeclarator(node) {
126
+ if (
127
+ node.id.type === "Identifier" &&
128
+ /room/i.test(node.id.name) &&
129
+ node.init &&
130
+ isRoomTemplateLiteral(node.init, patterns)
131
+ ) {
132
+ context.report({
133
+ node: node.init,
134
+ messageId: "noRawRoomString",
135
+ });
136
+ }
137
+ },
138
+ };
139
+ },
140
+ };
@@ -2,12 +2,12 @@
2
2
  * Allowed event name prefixes that are exempt from this rule.
3
3
  * These are documented exceptions for protocols that don't map to BaseService.
4
4
  */
5
- const DEFAULT_ALLOWED_PREFIXES = ["agentRunner:", "projectRunner:"]
5
+ const DEFAULT_ALLOWED_PREFIXES = ["agentRunner:", "projectRunner:"];
6
6
 
7
7
  /**
8
8
  * Allowed exact event names that are exempt from this rule.
9
9
  */
10
- const DEFAULT_ALLOWED_EVENTS = new Set(["subscribe", "unsubscribe"])
10
+ const DEFAULT_ALLOWED_EVENTS = new Set(["subscribe", "unsubscribe"]);
11
11
 
12
12
  /** @type {import('eslint').Rule.RuleModule} */
13
13
  export default {
@@ -51,45 +51,43 @@ export default {
51
51
  ],
52
52
  },
53
53
  create(context) {
54
- const options = context.options[0] || {}
55
- const allowedPrefixes = options.allowedPrefixes || DEFAULT_ALLOWED_PREFIXES
56
- const allowedEvents = new Set(
57
- options.allowedEvents || DEFAULT_ALLOWED_EVENTS
58
- )
54
+ const options = context.options[0] || {};
55
+ const allowedPrefixes = options.allowedPrefixes || DEFAULT_ALLOWED_PREFIXES;
56
+ const allowedEvents = new Set(options.allowedEvents || DEFAULT_ALLOWED_EVENTS);
59
57
 
60
58
  return {
61
59
  CallExpression(node) {
62
- const callee = node.callee
63
- if (callee.type !== "MemberExpression") return
64
- if (callee.property.type !== "Identifier") return
65
- if (callee.property.name !== "emit") return
60
+ const callee = node.callee;
61
+ if (callee.type !== "MemberExpression") return;
62
+ if (callee.property.type !== "Identifier") return;
63
+ if (callee.property.name !== "emit") return;
66
64
 
67
- const obj = callee.object
68
- if (obj.type !== "Identifier" || obj.name !== "socket") return
65
+ const obj = callee.object;
66
+ if (obj.type !== "Identifier" || obj.name !== "socket") return;
69
67
 
70
- const eventArg = node.arguments[0]
71
- if (!eventArg) return
68
+ const eventArg = node.arguments[0];
69
+ if (!eventArg) return;
72
70
 
73
71
  if (eventArg.type === "Literal" && typeof eventArg.value === "string") {
74
- const eventName = eventArg.value
72
+ const eventName = eventArg.value;
75
73
 
76
- if (allowedEvents.has(eventName)) return
74
+ if (allowedEvents.has(eventName)) return;
77
75
  if (allowedPrefixes.some((prefix) => eventName.startsWith(prefix))) {
78
- return
76
+ return;
79
77
  }
80
78
 
81
79
  context.report({
82
80
  node,
83
81
  messageId: "noRawSocketEmit",
84
82
  data: { eventName },
85
- })
83
+ });
86
84
  } else {
87
85
  context.report({
88
86
  node,
89
87
  messageId: "noRawSocketEmitDynamic",
90
- })
88
+ });
91
89
  }
92
90
  },
93
- }
91
+ };
94
92
  },
95
- }
93
+ };
@@ -2,7 +2,7 @@
2
2
  * Allowed event name prefixes that are exempt from this rule.
3
3
  * These are documented exceptions for protocols that don't map to BaseService.
4
4
  */
5
- const DEFAULT_ALLOWED_PREFIXES = ["agentRunner:", "projectRunner:"]
5
+ const DEFAULT_ALLOWED_PREFIXES = ["agentRunner:", "projectRunner:"];
6
6
 
7
7
  /** @type {import('eslint').Rule.RuleModule} */
8
8
  export default {
@@ -39,41 +39,41 @@ export default {
39
39
  ],
40
40
  },
41
41
  create(context) {
42
- const options = context.options[0] || {}
43
- const allowedPrefixes = options.allowedPrefixes || DEFAULT_ALLOWED_PREFIXES
42
+ const options = context.options[0] || {};
43
+ const allowedPrefixes = options.allowedPrefixes || DEFAULT_ALLOWED_PREFIXES;
44
44
 
45
45
  return {
46
46
  CallExpression(node) {
47
- const callee = node.callee
48
- if (callee.type !== "MemberExpression") return
49
- if (callee.property.type !== "Identifier") return
50
- if (callee.property.name !== "on") return
47
+ const callee = node.callee;
48
+ if (callee.type !== "MemberExpression") return;
49
+ if (callee.property.type !== "Identifier") return;
50
+ if (callee.property.name !== "on") return;
51
51
 
52
- const obj = callee.object
53
- if (obj.type !== "Identifier" || obj.name !== "socket") return
52
+ const obj = callee.object;
53
+ if (obj.type !== "Identifier" || obj.name !== "socket") return;
54
54
 
55
- const eventArg = node.arguments[0]
56
- if (!eventArg) return
55
+ const eventArg = node.arguments[0];
56
+ if (!eventArg) return;
57
57
 
58
58
  if (eventArg.type === "Literal" && typeof eventArg.value === "string") {
59
- const eventName = eventArg.value
59
+ const eventName = eventArg.value;
60
60
 
61
61
  if (allowedPrefixes.some((prefix) => eventName.startsWith(prefix))) {
62
- return
62
+ return;
63
63
  }
64
64
 
65
65
  context.report({
66
66
  node,
67
67
  messageId: "noRawSocketOn",
68
68
  data: { eventName },
69
- })
69
+ });
70
70
  } else {
71
71
  context.report({
72
72
  node,
73
73
  messageId: "noRawSocketOnDynamic",
74
- })
74
+ });
75
75
  }
76
76
  },
77
- }
77
+ };
78
78
  },
79
- }
79
+ };
@@ -0,0 +1,59 @@
1
+ /** @type {import('eslint').Rule.RuleModule} */
2
+ export default {
3
+ meta: {
4
+ type: "problem",
5
+ docs: {
6
+ description:
7
+ "Disallow raw string literals in Tooltip title props. Use useTranslations() for localization.",
8
+ },
9
+ messages: {
10
+ noRawTooltipStrings:
11
+ "Raw string '{{content}}' in Tooltip title should be localized. Use t('key') instead.",
12
+ },
13
+ schema: [],
14
+ },
15
+ create(context) {
16
+ return {
17
+ JSXOpeningElement(node) {
18
+ if (node.name.type !== "JSXIdentifier" || node.name.name !== "Tooltip") {
19
+ return;
20
+ }
21
+
22
+ const titleAttr = node.attributes.find(
23
+ (a) => a.type === "JSXAttribute" && a.name.name === "title",
24
+ );
25
+ if (!titleAttr) return;
26
+
27
+ // Check for title="raw string"
28
+ if (
29
+ titleAttr.value &&
30
+ (titleAttr.value.type === "StringLiteral" || titleAttr.value.type === "Literal") &&
31
+ typeof titleAttr.value.value === "string" &&
32
+ titleAttr.value.value.trim().length > 0
33
+ ) {
34
+ context.report({
35
+ node: titleAttr,
36
+ messageId: "noRawTooltipStrings",
37
+ data: { content: titleAttr.value.value },
38
+ });
39
+ }
40
+
41
+ // Check for title={"raw string"}
42
+ if (
43
+ titleAttr.value &&
44
+ titleAttr.value.type === "JSXExpressionContainer" &&
45
+ titleAttr.value.expression &&
46
+ titleAttr.value.expression.type === "Literal" &&
47
+ typeof titleAttr.value.expression.value === "string" &&
48
+ titleAttr.value.expression.value.trim().length > 0
49
+ ) {
50
+ context.report({
51
+ node: titleAttr,
52
+ messageId: "noRawTooltipStrings",
53
+ data: { content: titleAttr.value.expression.value },
54
+ });
55
+ }
56
+ },
57
+ };
58
+ },
59
+ };