@skapxd/eslint-opinionated 0.13.0 → 0.14.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 (39) hide show
  1. package/README.md +681 -0
  2. package/dist/astro/index.d.mts +14 -0
  3. package/dist/astro/index.d.ts +14 -0
  4. package/dist/astro/index.js +10 -0
  5. package/dist/astro/index.js.map +1 -1
  6. package/dist/astro/index.mjs +2 -2
  7. package/dist/{chunk-JA2ZO3KQ.mjs → chunk-C5JP4FQN.mjs} +2 -2
  8. package/dist/chunk-NBEAEKXG.mjs +140 -0
  9. package/dist/chunk-NBEAEKXG.mjs.map +1 -0
  10. package/dist/{chunk-QADXO5IL.mjs → chunk-QR35MHTW.mjs} +59 -3
  11. package/dist/chunk-QR35MHTW.mjs.map +1 -0
  12. package/dist/{chunk-DJXM5PMG.mjs → chunk-TZ5XPZGL.mjs} +2 -2
  13. package/dist/{chunk-UXF7WZ5B.mjs → chunk-YUKVSZZ4.mjs} +1646 -85
  14. package/dist/chunk-YUKVSZZ4.mjs.map +1 -0
  15. package/dist/index.js +1838 -90
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +10 -5
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/nest/index.d.mts +112 -0
  20. package/dist/nest/index.d.ts +112 -0
  21. package/dist/nest/index.js +248 -0
  22. package/dist/nest/index.js.map +1 -0
  23. package/dist/nest/index.mjs +8 -0
  24. package/dist/nest/index.mjs.map +1 -0
  25. package/dist/next/index.d.mts +14 -0
  26. package/dist/next/index.d.ts +14 -0
  27. package/dist/next/index.js +10 -0
  28. package/dist/next/index.js.map +1 -1
  29. package/dist/next/index.mjs +2 -2
  30. package/dist/shared/index.d.mts +43 -0
  31. package/dist/shared/index.d.ts +43 -0
  32. package/dist/shared/index.js +1727 -111
  33. package/dist/shared/index.js.map +1 -1
  34. package/dist/shared/index.mjs +2 -2
  35. package/package.json +6 -1
  36. package/dist/chunk-QADXO5IL.mjs.map +0 -1
  37. package/dist/chunk-UXF7WZ5B.mjs.map +0 -1
  38. /package/dist/{chunk-JA2ZO3KQ.mjs.map → chunk-C5JP4FQN.mjs.map} +0 -0
  39. /package/dist/{chunk-DJXM5PMG.mjs.map → chunk-TZ5XPZGL.mjs.map} +0 -0
package/README.md CHANGED
@@ -90,6 +90,28 @@ Quiero que un agente pueda generar código, pero que el proyecto le conteste:
90
90
 
91
91
  Eso es lo que estas reglas intentan proteger.
92
92
 
93
+ ## Los axiomas
94
+
95
+ Las reglas no son una colección de gustos: se derivan de ocho axiomas. Si una
96
+ regla nueva no es consecuencia de alguno, no entra. Si dos reglas chocan, gana
97
+ la que defiende el axioma más fundamental (el orden es jerárquico).
98
+
99
+ | # | Axioma | Reglas que lo ejecutan |
100
+ | --- | --- | --- |
101
+ | A1 | **Los estados imposibles son irrepresentables.** El tipo modela exactamente los estados válidos; lo inválido no compila. | `prefer-tagged-union-state`, `no-runtime-state-guard`, `requires-strict-tsconfig`, `@typescript-eslint/no-unnecessary-condition`, `no-explicit-any`, `consistent-type-definitions` |
102
+ | A2 | **Ningún efecto es invisible al tipo.** Si una operación puede fallar, su firma lo confiesa — no una convención oral ni un `throw` sorpresa. | `await-requires-result`, `no-try-catch`, `no-promise-chain`, `no-ad-hoc-ok-result`, `@typescript-eslint/no-floating-promises` |
103
+ | A3 | **La información no se destruye.** Un error que se transforma conserva su `cause`; uno que se detecta llega a alguien. Nadie decide "esto no importa" en silencio. | `result-error-requires-cause`, `result-error-requires-handling` |
104
+ | A4 | **Una unidad, una responsabilidad, un nombre semántico.** El árbol de archivos cuenta una historia; una clase expone una intención. | `one-root-function-per-file`, `max-public-methods`, `no-default-export`, `jsx-return-name-pascal-case`, `max-hook-size` |
105
+ | A5 | **Las decisiones se declaran, no se interpretan.** Cada rama es explícita y exhaustiva; un caso ignorado es una decisión visible, no un hueco. | `no-else`, `no-nested-if`, `prefer-ts-pattern`, `@typescript-eslint/ban-ts-comment`, el `void promesa()` de `no-floating-promises` |
106
+ | A6 | **Evidencia sobre convención.** Una regla decide por lo que el type-checker o los imports demuestran, no por cómo se llama un archivo o un campo. | la implementación type-aware de las reglas de Result, `nest-no-direct-instantiation` (@Injectable resuelto por símbolo), la exención ORM por decorador de `class-properties-require-readonly` |
107
+ | A7 | **Las fronteras son explícitas y únicas.** Lo que cruza una capa lo hace por un contrato, una sola vez, sin túneles. | `no-deep-relative-imports`, `no-tunnel-props`, `nest-no-result-response`, `nest-no-swagger-in-controllers`, `nest-no-inline-query-params` |
108
+ | A8 | **Inmutable por defecto.** La mutación es la excepción que se pide con evidencia, no el estado natural de las cosas. | `class-properties-require-readonly`, `no-accessors` |
109
+
110
+ A6 es distinto a los demás: no produce reglas, produce **cómo se implementan**
111
+ todas. Por eso las reglas de este paquete prefieren parser services y
112
+ provenance de imports antes que globs de nombres — el nombre es la evidencia
113
+ más débil que aceptamos, y solo como último recurso.
114
+
93
115
  ## Por qué las alternativas no bastan
94
116
 
95
117
  ### ESLint core
@@ -353,6 +375,9 @@ src/
353
375
  │ ├── rules.ts
354
376
  │ ├── configs/
355
377
  │ └── index.ts
378
+ ├── nest/
379
+ │ ├── configs.ts
380
+ │ └── index.ts
356
381
  ├── next/
357
382
  │ ├── configs.ts
358
383
  │ └── index.ts
@@ -365,6 +390,7 @@ src/
365
390
  | Módulo | Propósito |
366
391
  | --- | --- |
367
392
  | `@skapxd/eslint-opinionated/shared` | Reglas y presets comunes para backend, frontend y paquetes npm. |
393
+ | `@skapxd/eslint-opinionated/nest` | Presets específicos para NestJS. |
368
394
  | `@skapxd/eslint-opinionated/next` | Presets específicos para Next.js. |
369
395
  | `@skapxd/eslint-opinionated/astro` | Presets específicos para Astro. |
370
396
  | `@skapxd/eslint-opinionated` | Entry point principal con todas las reglas y configs. |
@@ -478,6 +504,68 @@ export default [
478
504
  ];
479
505
  ```
480
506
 
507
+ ### NestJS
508
+
509
+ ```js
510
+ import skapxd from "@skapxd/eslint-opinionated";
511
+
512
+ export default [
513
+ ...skapxd.configs.nest,
514
+ ];
515
+ ```
516
+
517
+ Nest trae un modelo de errores por excepciones (`HttpException` + exception
518
+ filters). El preset no pelea contra eso: asigna a cada capa su rol en el
519
+ pipeline de Result:
520
+
521
+ | Capa Nest | Rol | Contrato |
522
+ | --- | --- | --- |
523
+ | Services / use-cases | El dominio puro | Todo retorna `Promise<Result<T, DomainError>>`; `trySafe` en la frontera con Mongoose/Prisma/HTTP |
524
+ | Controllers | La frontera | Consumen el Result con `match()`: rama ok → DTO, rama err → `throw new HttpException(...)`. El `throw` aquí es el idioma del framework, no una fuga |
525
+ | Exception filter global | **El suelo del sistema** | Recibe todo lo que escapó, con el `cause` completo → telemetría/log (ver "El suelo del sistema") |
526
+
527
+ Detalles del preset:
528
+
529
+ - Aplica a `src/**/*.ts` — `dev/`, `scripts/`, `e2e/` e `integration-test/`
530
+ quedan fuera a propósito: no son la app.
531
+ - Los entrypoints (`main.ts`, `instrumentation.ts`, `app-cluster.ts`) están
532
+ exentos de `await-requires-result`: el bootstrap debe crashear ruidoso.
533
+ Con `no-floating-promises` activa, el clásico `bootstrap();` del `main.ts`
534
+ se escribe `void bootstrap();` — fire-and-forget declarado.
535
+ - Los specs colocados (`*.spec.ts`, `*.e2e-spec.ts`) relajan
536
+ `await-requires-result`, `no-try-catch`, `result-error-requires-handling` y
537
+ `@typescript-eslint/no-non-null-assertion` (el `!` sobre un fixture es el
538
+ arrange del test): un test awaitea helpers libremente y descartar un Result
539
+ en una aserción no es perder un trace. `no-floating-promises` sigue activa
540
+ en specs: un `await` olvidado es un falso verde.
541
+ - Activa `skapxd/nest-no-result-response` (ver su sección): un controller
542
+ jamás retorna el Result crudo.
543
+ - **El contrato Swagger vive en los DTOs, no en el controller.** El preset
544
+ asume el plugin `@nestjs/swagger` activo en `nest-cli.json` (introspecciona
545
+ query/params/body y tipo de retorno solo): `nest-dto-requires-api-property`
546
+ exige `@ApiProperty` en toda propiedad pública de un `*.dto.ts`, y
547
+ `nest-no-swagger-in-controllers` prohíbe los decoradores redundantes
548
+ (`@ApiOperation`, `@ApiResponse`, `@ApiParam`, ...) en los controllers —
549
+ solo se permiten los que el plugin no puede inferir: `ApiExcludeEndpoint`,
550
+ `ApiTags`, `ApiBearerAuth`, `ApiConsumes`/`ApiBody` (uploads multipart).
551
+ - **Los DTOs de input validan en runtime**: `nest-dto-requires-validation`
552
+ exige class-validator en cada propiedad, coherencia `?` ↔ `@IsOptional`, y
553
+ `@Type` de class-transformer junto a `@ValidateNested`. Los DTOs de
554
+ respuesta (`out-*`, `*-response`, ...) quedan exentos.
555
+ - **Una clase = una responsabilidad**: `max-public-methods` (de las reglas
556
+ base) corre con los hooks de Nest inyectados vía `ignore`, y se apaga en
557
+ `*.controller.ts`/`*.gateway.ts` donde el framework dicta la forma.
558
+ `nest-no-direct-instantiation` (dependencias por constructor, no `new`) en
559
+ `*.service.ts`; `nest-no-inline-query-params` en `*.controller.ts` (2+
560
+ query params → DTO consolidado).
561
+ - **La configuración del proyecto también se lintea**: las premisas de las
562
+ que dependen las demás reglas se verifican, no se asumen.
563
+ `nest-requires-swagger-plugin` lee el `nest-cli.json` real (subiendo desde
564
+ `src/main.ts`) y exige el plugin `@nestjs/swagger`;
565
+ `nest-validation-pipe-config` exige `transform: true` (sin él, los `@Type`
566
+ de los DTOs no hacen nada) y `whitelist: true` (sin él, las props sin
567
+ decorador pasan crudas) en todo `new ValidationPipe`.
568
+
481
569
  ### Astro
482
570
 
483
571
  ```js
@@ -573,14 +661,29 @@ de cada regla):
573
661
  | `async-functions-return-result` | `allowFilePatterns` (globs), `allowNamePatterns` (regex), `checkMissingReturnType`, `checkMissingReturnTypeWhenCallNames`, `requireCallNames`, `promiseTypeNames`, `resultTypeNames` |
574
662
  | `await-requires-result` | `allowFilePatterns` (globs), `trySafeCallNames` |
575
663
  | `max-hook-size` | `maxLines`, `maxUseState` |
664
+ | `class-properties-require-readonly` | `allowFilePatterns` (globs), `allowPropertyPatterns` (regex), `ormModuleSources` (default `["@nestjs/mongoose", "typeorm"]`) |
665
+ | `max-public-methods` | `allowFilePatterns` (globs), `max` (default `1`), `ignore` (aditivo a los hooks de Nest) |
666
+ | `no-accessors` | `allowFilePatterns` (globs) |
667
+ | `nest-dto-requires-api-property` | `allowFilePatterns` (globs), `dtoFilePatterns` (default `["*.dto.ts"]`), `apiPropertyDecoratorNames` |
668
+ | `nest-dto-requires-validation` | `allowFilePatterns` (globs), `dtoFilePatterns`, `outputDtoFilePatterns`, `outputDtoClassPatterns` (regex), `optionalDecoratorNames` |
669
+ | `nest-no-direct-instantiation` | `allowFilePatterns` (globs), `internalPatterns` (regex), `allowedPatterns` (regex), `allowedClassPatterns` (regex, default `(Error|Exception|Event)$`) |
670
+ | `nest-no-inline-query-params` | `allowFilePatterns` (globs), `max` (default `1`) |
671
+ | `nest-no-result-response` | `allowFilePatterns` (globs), `controllerDecoratorNames` (default `["Controller"]`) |
672
+ | `nest-no-swagger-in-controllers` | `allowFilePatterns` (globs), `allowedDecoratorNames`, `controllerDecoratorNames` |
673
+ | `nest-requires-swagger-plugin` | `allowFilePatterns` (globs), `mainFilePatterns` (default `["src/main.ts"]`) |
674
+ | `nest-validation-pipe-config` | `allowFilePatterns` (globs), `requiredPipeOptions` (default `["transform", "whitelist"]`) |
576
675
  | `no-deep-relative-imports` | `maxDepth` |
577
676
  | `no-default-export` | `allowFilePatterns` (globs, aditivos a los integrados) |
677
+ | `no-else` | `allowFilePatterns` (globs) |
578
678
  | `no-emoji` | `allowFilePatterns` (globs) |
579
679
  | `no-functions-inside-components` | `allowJsxCallbacks`, `allowArrayMapCallbacks` (ambas `true` por defecto) |
580
680
  | `no-nested-if` | `allowFilePatterns` (globs) |
581
681
  | `no-promise-chain` | `methods` |
682
+ | `no-runtime-state-guard` | `allowFilePatterns` (globs) |
582
683
  | `no-tunnel-props` | `allowFilePatterns` (globs), `allowPropPatterns` (regex) |
583
684
  | `prefer-abort-signal` | `allowFilePatterns` (globs), `effectNames` (default `["useEffect", "useLayoutEffect"]`) |
685
+ | `prefer-tagged-union-state` | `allowFilePatterns` (globs), `loadingPatterns` (regex, en minúsculas), `errorPatterns` (regex, en minúsculas) |
686
+ | `requires-strict-tsconfig` | `allowFilePatterns` (globs), `anchorFilePatterns` (globs), `requiredCompilerOptions` |
584
687
  | `result-error-requires-handling` | `allowFilePatterns` (globs) |
585
688
 
586
689
  Los `allowFilePatterns` de todas las reglas son **globs** (`*` un segmento,
@@ -594,18 +697,33 @@ matchea en cualquier carpeta). Las 7 reglas restantes no tienen opciones: su
594
697
  | --- | --- |
595
698
  | `skapxd/one-root-function-per-file` | Un archivo, una función top-level semántica. |
596
699
  | `skapxd/async-functions-return-result` | Funciones async de dominio deben retornar `Promise<Result<...>>`. **Apagada por defecto; opt-in** (ver motivos en su sección). |
700
+ | `skapxd/requires-strict-tsconfig` | El `tsconfig` debe ser implacable (`strict`, `noImplicitReturns`, `noUncheckedIndexedAccess`): sin ellos, el compilador no puede hacer irrepresentable lo inválido. |
597
701
  | `skapxd/result-error-requires-cause` | Un `Result.err` derivado debe preservar `cause: result.error`. |
598
702
  | `skapxd/result-error-requires-handling` | Prohíbe descartar en silencio un Result fallido: el error se transforma o se entrega, nunca se ignora. |
599
703
  | `skapxd/await-requires-result` | Todo `await` debe resolver en un `Result`: o la función llamada retorna `Promise<Result<...>>` (preferido) o se envuelve en `trySafe`. **Obligatoria en todos los presets tipados.** |
600
704
  | `skapxd/no-ad-hoc-ok-result` | Evita contratos `{ ok: ... }` hechos a mano en async exports. |
601
705
  | `skapxd/max-hook-size` | Marca hooks grandes o con demasiados `useState`. |
706
+ | `skapxd/class-properties-require-readonly` | Toda propiedad de clase es `readonly`: el cambio se modela con instancias nuevas, no con mutación. |
707
+ | `skapxd/max-public-methods` | Una clase, una responsabilidad: máximo N métodos públicos (default 1). Agnóstica al framework, en las reglas base; el preset `nest` le inyecta sus hooks. |
708
+ | `skapxd/no-accessors` | Prohíbe `get`/`set`: un método explícito dice la verdad; el accessor esconde computación (y métodos disfrazados). |
602
709
  | `skapxd/jsx-return-name-pascal-case` | Funciones que retornan JSX deben nombrarse como componentes. |
710
+ | `skapxd/nest-dto-requires-api-property` | Toda propiedad pública de un `*.dto.ts` lleva `@ApiProperty`: el contrato HTTP se documenta en el DTO. Preset `nest`. |
711
+ | `skapxd/nest-dto-requires-validation` | Los DTOs de input validan en runtime: class-validator en cada propiedad, `@IsOptional` si hay `?`, `@Type` junto a `@ValidateNested`. Preset `nest`. |
712
+ | `skapxd/nest-no-direct-instantiation` | Prohíbe `new` sobre imports internos en services: las dependencias entran por el constructor (DI). Preset `nest`. |
713
+ | `skapxd/nest-no-inline-query-params` | Dos o más `@Query('x')`/`@ApiQuery` individuales son un DTO disfrazado: consolida en `@Query() filters: Dto`. Preset `nest`. |
714
+ | `skapxd/nest-no-result-response` | Los métodos de un `@Controller` no retornan `Result`: el envelope se serializaría al cliente. La activa el preset `nest`. |
715
+ | `skapxd/nest-no-swagger-in-controllers` | Los controllers no se llenan de decoradores de swagger; el plugin introspecciona los DTOs. Preset `nest`. |
716
+ | `skapxd/nest-requires-swagger-plugin` | `nest-cli.json` debe tener el plugin `@nestjs/swagger`: la premisa de las reglas de swagger, verificada. Preset `nest`. |
717
+ | `skapxd/nest-validation-pipe-config` | Todo `new ValidationPipe` configura `transform` y `whitelist`: la premisa de las reglas de DTOs. Preset `nest`. |
603
718
  | `skapxd/no-deep-relative-imports` | Limita la profundidad de los imports relativos (`../`). |
604
719
  | `skapxd/no-default-export` | Prohíbe `export default`; el nombre del símbolo es el contrato. Exime configs/stories y, en el preset `next`, los entrypoints del App Router. |
720
+ | `skapxd/no-else` | Prohíbe `else`/`else if`: el else es el estado sin nombre. Retorno anticipado, ternario simple o `match()`. |
605
721
  | `skapxd/no-emoji` | Prohíbe emojis en strings y JSX; cada sistema los renderiza distinto. Usa un icono SVG. |
606
722
  | `skapxd/no-nested-if` | Prohíbe `if` anidados: retorno anticipado o `match()`. Menos carga cognitiva y sin puntos ciegos para las demás reglas. |
723
+ | `skapxd/no-runtime-state-guard` | Prohíbe `if (this.x) throw` en métodos: el estado inválido se hace irrepresentable en el tipo, no se vigila en runtime. |
607
724
  | `skapxd/no-tunnel-props` | Ninguna prop viaja más de un nivel: quien la recibe no puede reenviarla a otro componente. Mata el prop drilling. |
608
725
  | `skapxd/prefer-abort-signal` | Listeners en efectos se limpian con `AbortController` (`{ signal }` + `abort()`), no con `removeEventListener`. |
726
+ | `skapxd/prefer-tagged-union-state` | Prohíbe estados inconsistentes representables: flag de loading + campo de error independientes → unión etiquetada. |
609
727
  | `skapxd/no-functions-inside-components` | Prohíbe definir funciones dentro de componentes React. |
610
728
  | `skapxd/no-try-catch` | Prohíbe `try/catch`; usa `trySafe` de `@skapxd/result`. |
611
729
  | `skapxd/no-promise-chain` | Prohíbe `.then/.catch/.finally`; usa `await` (+ `trySafe`). |
@@ -708,6 +826,78 @@ Todas las opciones, con sus defaults:
708
826
  }]
709
827
  ```
710
828
 
829
+ ### `skapxd/requires-strict-tsconfig`
830
+
831
+ Todo el sistema descansa en que el compilador pueda hacer irrepresentables
832
+ los estados inválidos — y eso exige un `tsconfig` implacable. Esta regla lee
833
+ el `tsconfig.json` **real** del proyecto (con la API de TypeScript: soporta
834
+ JSONC y resuelve la cadena de `extends`) y exige los flags, anclada a un
835
+ entrypoint para reportar una vez por proyecto:
836
+
837
+ - `strict` — sin él, el sistema de tipos está apagado a medias.
838
+ - `noImplicitReturns` — una rama que sale sin valor deja de ser silenciosa
839
+ (la pareja en compilación de `no-else`).
840
+ - `noUncheckedIndexedAccess` — `array[i]` y los accesos dinámicos confiesan
841
+ su `undefined` en vez de fingir.
842
+
843
+ `strict: true` **no implica** los otros dos: hay que pedirlos explícitos.
844
+ Fuera del default, a propósito: `exactOptionalPropertyTypes` y
845
+ `strictPropertyInitialization` chocan con los DTOs de class-transformer y
846
+ con muchas librerías — se agregan vía `requiredCompilerOptions` si el
847
+ proyecto los soporta.
848
+
849
+ Además, los **presets tipados activan reglas curadas de typescript-eslint**
850
+ (que ya es peer dependency — no se reimplementan):
851
+
852
+ - `@typescript-eslint/no-explicit-any` — `any` apaga el sistema de tipos:
853
+ todo el esfuerzo muere donde aparece uno.
854
+ - `@typescript-eslint/consistent-type-definitions` con `type` — las uniones
855
+ discriminadas son types.
856
+ - `@typescript-eslint/no-floating-promises` — cierra el hueco que
857
+ `await-requires-result` no ve: una llamada async **sin** `await` no produce
858
+ `AwaitExpression`, así que el rechazo muere sin pasar por `trySafe`
859
+ (medido: 12 promesas flotantes vivas en un backend Nest real). La única
860
+ salida es `void promesa()`: fire-and-forget declarado y greppeable.
861
+ - `@typescript-eslint/no-non-null-assertion` — `!` es "cállate, yo sé más
862
+ que tú" dicho al compilador. Si no puede ser nulo, que lo diga el tipo.
863
+ (En `nest/tests` queda apagada: el `!` sobre un fixture cuya existencia el
864
+ propio test garantiza es el arrange, no una mentira.)
865
+ - `@typescript-eslint/no-unnecessary-condition` — la generalización
866
+ type-aware de `no-runtime-state-guard`: si el tipo dice que un estado es
867
+ imposible, el guard defensivo sobra; si el guard hace falta, lo que está
868
+ mal es el tipo. Por eso esta regla y `requires-strict-tsconfig` van
869
+ juntas: sin `noUncheckedIndexedAccess`, `array[i]` miente y la regla
870
+ acusaría guards necesarios.
871
+ - `@typescript-eslint/ban-ts-comment` — un error de tipos se arregla
872
+ modelando mejor, no silenciando la alarma: `@ts-ignore` y `@ts-nocheck`
873
+ prohibidos. `@ts-expect-error` **con descripción** queda permitido: es la
874
+ forma legítima de testear que un estado inválido de verdad no compila.
875
+
876
+ Ausencias deliberadas, no olvidos:
877
+
878
+ | Regla ausente | Por qué |
879
+ | --- | --- |
880
+ | `switch-exhaustiveness-check` | `prefer-ts-pattern` prohíbe el `switch` entero; `match().exhaustive()` da la misma garantía sin él. |
881
+ | `prefer-readonly` | Superada por `class-properties-require-readonly`: exige `readonly` en la declaración, no solo en privados nunca reasignados. |
882
+ | `strict-boolean-expressions` | Castiga narrowing legítimo por cientos (560 hallazgos en un backend real) sin hacer irrepresentable ningún estado nuevo. Ruido, no señal. |
883
+ | `explicit-module-boundary-types` | Los contratos que importan ya están gobernados (`await-requires-result`, `nest-no-result-response`); anotar el resto es ceremonia (198 hallazgos) que la inferencia resuelve sin perder garantías. |
884
+ | `prefer-readonly-parameter-types` | Impracticable con cualquier parámetro que venga de una librería externa. |
885
+
886
+ Complemento recomendado (fuera del alcance de un linter): **tests a nivel de
887
+ tipos**. Si el dominio vive en los tipos, los tipos también se testean — con
888
+ [`expectTypeOf` de vitest](https://vitest.dev/guide/testing-types) (ya está en
889
+ tu stack, sin instalar `tsd`) o con `@ts-expect-error` descrito, que es
890
+ exactamente el caso que `ban-ts-comment` deja abierto:
891
+
892
+ ```ts
893
+ import { expectTypeOf } from "vitest";
894
+
895
+ test("un pedido cancelado no puede tener trackingId", () => {
896
+ expectTypeOf<Extract<Order, { status: "cancelled" }>>()
897
+ .not.toHaveProperty("trackingId");
898
+ });
899
+ ```
900
+
711
901
  ### `skapxd/result-error-requires-cause`
712
902
 
713
903
  Evita perder el error original al transformar un `Result` fallido:
@@ -885,6 +1075,77 @@ Sirve para código de pegamento, pero deja el error sin modelar (`Result<T,
885
1075
  unknown>`). Cuando la misma operación se repite o el error importa, el mensaje
886
1076
  de la regla empuja hacia el camino 1.
887
1077
 
1078
+ ### `skapxd/nest-no-swagger-in-controllers`
1079
+
1080
+ La contracara de la anterior: con el plugin de `@nestjs/swagger` activo en
1081
+ `nest-cli.json`, los decoradores de documentación en el controller son ruido
1082
+ redundante — el plugin ya introspecciona los DTOs de input y el tipo de
1083
+ retorno. Un controller lleno de `@ApiOperation`/`@ApiResponse`/`@ApiParam`
1084
+ entierra la lógica de la frontera bajo metadatos que viven mejor en el DTO:
1085
+
1086
+ ```ts
1087
+ @Controller("users")
1088
+ export class UsersController {
1089
+ @ApiOperation({ summary: "Busca un usuario" }) // ❌ redundante con el plugin
1090
+ @ApiResponse({ status: 200, type: UserDto }) // ❌ el tipo de retorno ya lo dice
1091
+ @ApiParam({ name: "id" }) // ❌ el DTO de params ya lo dice
1092
+ @Get(":id")
1093
+ findOne(@Param() params: FindUserParamsDto): Promise<UserDto> { ... }
1094
+ }
1095
+ ```
1096
+
1097
+ Solo se permiten los decoradores que el plugin **no puede inferir**
1098
+ (`allowedDecoratorNames`, configurable): `ApiExcludeEndpoint` (ocultar rutas
1099
+ internas), `ApiTags` (agrupación), `ApiBearerAuth` (auth), y
1100
+ `ApiConsumes`/`ApiBody` (uploads multipart, que la introspección no ve).
1101
+
1102
+ La detección compara contra los **imports reales de `@nestjs/swagger`** del
1103
+ archivo: un decorador propio que se llame `ApiOperation` no se toca. Solo
1104
+ aplica dentro de clases `@Controller`.
1105
+
1106
+ ### `skapxd/nest-requires-swagger-plugin`
1107
+
1108
+ Las reglas de swagger del preset (`nest-no-swagger-in-controllers`,
1109
+ `nest-dto-requires-api-property`) descansan sobre una premisa: el plugin
1110
+ `@nestjs/swagger` activo en `nest-cli.json`, que introspecciona DTOs y tipos
1111
+ de retorno. Esta regla **verifica la premisa en vez de asumirla**: anclada al
1112
+ entrypoint (`mainFilePatterns`, default `src/main.ts`, un reporte por
1113
+ proyecto), sube por las carpetas hasta el `nest-cli.json` real y exige:
1114
+
1115
+ ```jsonc
1116
+ // nest-cli.json
1117
+ {
1118
+ "compilerOptions": {
1119
+ "plugins": ["@nestjs/swagger"] // ✅ (también acepta { "name": "..." })
1120
+ }
1121
+ }
1122
+ ```
1123
+
1124
+ Sin el plugin, el swagger queda vacío — y como el preset prohíbe documentarlo
1125
+ a mano en los controllers, el error te lo dice en el primer lint, no en el
1126
+ primer deploy.
1127
+
1128
+ ### `skapxd/nest-validation-pipe-config`
1129
+
1130
+ La otra premisa verificada: todo `new ValidationPipe(...)` (el real, importado
1131
+ de `@nestjs/common`) debe configurar las dos opciones que hacen reales los
1132
+ contratos de los DTOs:
1133
+
1134
+ ```ts
1135
+ app.useGlobalPipes(
1136
+ new ValidationPipe({
1137
+ transform: true, // sin él, class-transformer no corre: los @Type no hacen NADA
1138
+ whitelist: true, // sin él, las props sin decorador pasan crudas al dominio
1139
+ // ...el resto (exceptionFactory, transformOptions) es tuyo
1140
+ }),
1141
+ );
1142
+ ```
1143
+
1144
+ `new ValidationPipe()` sin opciones, con una faltante o con `transform: false`
1145
+ se reporta. Si las opciones llegan como variable, se resuelve por scope; un
1146
+ identifier irresoluble o un spread reciben el beneficio de la duda.
1147
+ `requiredPipeOptions` es configurable (p. ej. añadir `forbidNonWhitelisted`).
1148
+
888
1149
  ### `skapxd/no-ad-hoc-ok-result`
889
1150
 
890
1151
  Prohíbe que una función async **exportada** retorne objetos literales con la
@@ -945,6 +1206,287 @@ de componentes detectan "componente" por nombre PascalCase, así que una
945
1206
  función `renderX` que devuelve JSX escaparía de ellas. Esta la captura y
946
1207
  fuerza el rename — y con el nombre corregido, las demás ya la ven.
947
1208
 
1209
+ ### `skapxd/no-accessors`
1210
+
1211
+ Prohíbe `get`/`set` en clases y objetos literales. Un accessor es un método
1212
+ con sintaxis de propiedad: esconde computación tras un acceso que parece
1213
+ inocente (`config.token` que en realidad ejecuta código), y abre la puerta al
1214
+ **método disfrazado** — un `get sendMessage() { return (...) => ... }` que
1215
+ escapaba de `max-public-methods`:
1216
+
1217
+ ```ts
1218
+ class Connection {
1219
+ get socket() { return this.current; } // ❌ computación disfrazada de propiedad
1220
+ socket() { return this.current; } // ✅ el call site dice la verdad: socket()
1221
+ }
1222
+ ```
1223
+
1224
+ Si algo es un dato, es una propiedad `readonly`; si algo es comportamiento,
1225
+ es un método explícito que cuenta en la superficie pública. No hay tercera
1226
+ categoría.
1227
+
1228
+ ### `skapxd/class-properties-require-readonly`
1229
+
1230
+ Toda propiedad de clase (incluidas las parameter properties del constructor)
1231
+ lleva `readonly`. El estado mutable es la raíz de los **estados
1232
+ inconsistentes** — la misma enfermedad del `useState` con `isLoading`,
1233
+ `error` y `value` llenos a la vez que motivó este paquete: si los campos
1234
+ pueden mutar por separado, las combinaciones imposibles se vuelven posibles.
1235
+ El cambio se modela creando instancias nuevas:
1236
+
1237
+ ```ts
1238
+ class Loan {
1239
+ constructor(
1240
+ readonly amount: number, // ✅
1241
+ private readonly term: number, // ✅ parameter property también
1242
+ ) {}
1243
+
1244
+ withAmount(amount: number): Loan {
1245
+ return new Loan(amount, this.term); // el "cambio": una instancia nueva
1246
+ }
1247
+ }
1248
+
1249
+ class Cache {
1250
+ private entries: string[] = []; // ❌ private no exime: mutable es mutable
1251
+ }
1252
+ ```
1253
+
1254
+ La mutación inherente (la conexión de un socket que se reemplaza al
1255
+ reconectar) **se declara visible** en `allowPropertyPatterns: ["^currentSocket$"]`
1256
+ — una decisión en la config, greppeable, no un default silencioso.
1257
+
1258
+ **Compatibilidad con NestJS, investigada y verificada:**
1259
+
1260
+ - **DTOs ✅ sin fricción** (verificado empíricamente con class-transformer +
1261
+ class-validator reales): `readonly` es chequeo de compilación que se borra
1262
+ en runtime — `plainToInstance` asigna, `@Type` convierte, los anidados se
1263
+ instancian y la validación corre igual. El issue conocido de
1264
+ class-transformer ([typestack/class-transformer#250](https://github.com/typestack/class-transformer/issues/250))
1265
+ es sobre `private readonly` detrás de *getters* (accessors) — patrón que
1266
+ `no-accessors` ya prohíbe.
1267
+ - **Capa de persistencia ⚠️ exención POR PROPIEDAD, no por archivo**: una
1268
+ propiedad decorada por el ORM (`@Prop` de `@nestjs/mongoose`, `@Column` y
1269
+ compañía de `typeorm` — verificados contra los imports reales,
1270
+ `ormModuleSources` configurable) le pertenece al ORM y a su modelo de
1271
+ mutación (`doc.campo = x; await doc.save()` no compila contra readonly).
1272
+ La precisión importa: una propiedad **sin** `@Prop` dentro de un
1273
+ `*.schema.ts` es estado de clase normal (campos virtuales, caches) y sí
1274
+ exige `readonly` — la exención por nombre de archivo la habría silenciado.
1275
+ - **Cuidado con los TIPOS array readonly** (`tags: readonly string[]`,
1276
+ `ReadonlyArray<T>`): el plugin de `@nestjs/swagger` degrada su inferencia
1277
+ con ellos ([nestjs/swagger#2413](https://github.com/nestjs/swagger/issues/2413)).
1278
+ Esta regla exige el modificador en la *propiedad* (`readonly tags: string[]`),
1279
+ que es inocuo para el plugin — no uses los tipos array readonly en DTOs.
1280
+
1281
+ ### `skapxd/max-public-methods`
1282
+
1283
+ El `one-root-function-per-file` del mundo de clases: **una clase, una
1284
+ responsabilidad** — máximo `max` métodos públicos (default `1`). Es la regla
1285
+ que convierte un `loans.service.ts` de 1965 líneas en una carpeta de casos de
1286
+ uso (`find-apc-score.service.ts`, `create-signature.service.ts`, ...).
1287
+
1288
+ Es **agnóstica al framework** y vive en las reglas base: una clase en Nest,
1289
+ Astro, Next o un proyecto Vite responde al mismo contrato. El conocimiento
1290
+ del framework lo inyecta cada preset vía `ignore` — la regla en sí no sabe
1291
+ qué es NestJS.
1292
+
1293
+ ```ts
1294
+ // ❌ dos casos de uso conviviendo
1295
+ export class ApcService {
1296
+ async getScore(id: string) { ... }
1297
+ async refreshScore(id: string) { ... }
1298
+ }
1299
+
1300
+ // ✅ un caso de uso con su séquito privado
1301
+ export class FindApcScoreService {
1302
+ constructor(private readonly repository: ApcRepository) {}
1303
+ async execute(id: string) { return this.normalize(...); }
1304
+ private normalize(raw: unknown) { ... }
1305
+ }
1306
+ ```
1307
+
1308
+ No cuentan: constructor, getters/setters, `private`/`protected`, `#privados`
1309
+ y el prefijo `_`. `ignore` exime nombres por opción — así el **preset `nest`**
1310
+ inyecta sus hooks (`onModuleInit`, `onApplicationBootstrap`, `canActivate`,
1311
+ `intercept`, `transform`, `catch`, `use`, ...): callbacks que el framework
1312
+ llama, no superficie pública. Fuera de Nest esos nombres no significan nada y
1313
+ cuentan como cualquier método.
1314
+
1315
+ El preset `nest` además la **apaga en `*.controller.ts` y `*.gateway.ts`**:
1316
+ ahí la forma la dicta el framework (un método por ruta/evento) y el límite no
1317
+ aporta semántica. El mensaje de error es un playbook de refactor completo
1318
+ (nombres semánticos, extracción de estado compartido, actualización del
1319
+ módulo y los imports) pensado para que un agente lo ejecute solo.
1320
+
1321
+ ### `skapxd/nest-dto-requires-api-property`
1322
+
1323
+ El contrato HTTP — query, params, body y respuesta — se documenta en el DTO,
1324
+ no en el controller. Toda propiedad **pública de instancia** de una clase en
1325
+ un `*.dto.ts` debe llevar `@ApiProperty` o `@ApiPropertyOptional`:
1326
+
1327
+ ```ts
1328
+ // create-user.dto.ts
1329
+ export class CreateUserDto {
1330
+ @ApiProperty({ description: "Nombre legal completo", example: "Ana Pérez" })
1331
+ name: string; // ✅
1332
+
1333
+ email: string; // ❌ sin documentar
1334
+
1335
+ @IsString()
1336
+ phone: string; // ❌ class-validator no documenta
1337
+ }
1338
+ ```
1339
+
1340
+ El plugin de `@nestjs/swagger` infiere el **tipo**, pero la `description` y el
1341
+ `example` son intención tuya — y son lo que convierte el swagger en un
1342
+ contrato legible (y en un buen cliente generado). Las propiedades `private`,
1343
+ `protected`, `#privadas` y `static` no se exigen: swagger no las serializa.
1344
+ `dtoFilePatterns` ajusta la convención de archivos si no usas `*.dto.ts`.
1345
+
1346
+ ### `skapxd/nest-dto-requires-validation`
1347
+
1348
+ El tipo de TypeScript desaparece en runtime: un DTO de input sin
1349
+ class-validator es un contrato de mentira — el `ValidationPipe` deja pasar
1350
+ cualquier cosa (o la descarta en silencio con `whitelist`). Tres contratos en
1351
+ una regla:
1352
+
1353
+ ```ts
1354
+ export class CreateLoanDto {
1355
+ @ApiProperty()
1356
+ @IsNumber() // 1. ✅ toda propiedad valida en runtime
1357
+ @IsNotEmpty()
1358
+ amount: number;
1359
+
1360
+ @ApiPropertyOptional()
1361
+ @IsOptional() // 2. ✅ el `?` del tipo y el runtime coinciden
1362
+ @IsNumber()
1363
+ termMonths?: number;
1364
+
1365
+ @ApiProperty()
1366
+ @ValidateNested()
1367
+ @Type(() => AddressDto) // 3. ✅ sin @Type, la validación anidada NO corre
1368
+ address: AddressDto;
1369
+ }
1370
+ ```
1371
+
1372
+ 1. **Toda propiedad pública** lleva al menos un decorador de class-validator.
1373
+ 2. **`?` exige `@IsOptional`** (o `@ValidateIf`): si el tipo dice opcional y el
1374
+ runtime la exige, el contrato miente.
1375
+ 3. **`@ValidateNested` exige `@Type(() => Clase)`** de class-transformer: sin
1376
+ él, el objeto anidado llega como plain object y la validación anidada no
1377
+ corre — el bug silencioso clásico (esta regla lo encontró en producción).
1378
+
1379
+ Los **DTOs de respuesta quedan exentos** por dos vías: nombre de archivo
1380
+ (`outputDtoFilePatterns`: `out-*`, `output-*`, `*-response`, `*-result`,
1381
+ `*-output`) y **nombre de clase** (`outputDtoClassPatterns`, regex, default
1382
+ `(Response|Result|Output)(Dto)?$`) — porque un `UploadDocumentResponseDto`
1383
+ puede vivir en un archivo de nombre neutro (`upload-document.dto.ts`) o
1384
+ compartir archivo con DTOs de input, y la exención de la clase no contagia a
1385
+ sus vecinas. El server los produce, no los recibe. La detección compara
1386
+ contra los imports reales de `class-validator`/`class-transformer`, así que
1387
+ un decorador casero homónimo no engaña a la regla.
1388
+
1389
+ **El caso Multer** queda cubierto por el conjunto: el archivo llega como
1390
+ parámetro (`@UploadedFiles() files: Express.Multer.File[]`), nunca en un DTO
1391
+ validable; el schema multipart se documenta inline en el controller con
1392
+ `@ApiConsumes` + `@ApiBody` (permitidos por `nest-no-swagger-in-controllers`:
1393
+ la introspección no ve multipart); y el DTO de respuesta del upload queda
1394
+ exento por nombre de clase. La validación del archivo en sí (tamaño, mimetype)
1395
+ va donde Nest la diseñó: `ParseFilePipe` en el parámetro, no class-validator.
1396
+
1397
+ ### `skapxd/nest-no-direct-instantiation`
1398
+
1399
+ En un service, `new FooService()` sobre un import **interno del proyecto**
1400
+ esquiva el contenedor de DI: NestJS no resuelve sus dependencias, no
1401
+ participa del lifecycle, y la clase deja de ser testeable con mocks. Las
1402
+ dependencias entran por el constructor:
1403
+
1404
+ ```ts
1405
+ import { FooService } from "#/modules/foo/foo.service";
1406
+
1407
+ const foo = new FooService(); // ❌ esquiva la DI
1408
+
1409
+ constructor(private readonly fooService: FooService) {} // ✅ NestJS resuelve
1410
+ ```
1411
+
1412
+ La robustez viene en capas:
1413
+
1414
+ 1. **Los globals del runtime nunca se marcan** (`new Date()`, `new Map()`,
1415
+ `new AbortController()`): la regla parte de los **imports internos**
1416
+ (`internalPatterns`: alias `#/`, `@/` y relativos), y un global no se
1417
+ importa. Las librerías externas (`new Logger(...)`) también libres, y los
1418
+ `import type` no cuentan.
1419
+ 2. **Exención por nombre de clase** (`allowedClassPatterns`, default
1420
+ `(Error|Exception|Event)$`): errores, excepciones y eventos de dominio se
1421
+ construyen, no se inyectan — vivan en el archivo que vivan.
1422
+ 3. **La capa type-aware** (con `projectService`, que el preset trae): la
1423
+ regla resuelve el símbolo de la clase importada y pregunta por el
1424
+ decorador `@Injectable`. Sin el decorador es una clase de valor (un DTO,
1425
+ un mapper puro) y el `new` es legítimo; con él, pertenece al contenedor y
1426
+ se reporta. Irresoluble → conservador, se reporta. En un proyecto real
1427
+ esta capa eliminó el 100% de los falsos positivos restantes.
1428
+
1429
+ `allowedPatterns` (regex de sources) sigue disponible para convenciones
1430
+ propias. El preset la activa en `*.service.ts`.
1431
+
1432
+ ### `skapxd/nest-no-inline-query-params`
1433
+
1434
+ Dos o más `@Query('x')` individuales (o `@ApiQuery` sueltos) en un handler
1435
+ son un DTO disfrazado — sin validación automática, sin tipos de verdad y con
1436
+ el controller enterrado en decoradores:
1437
+
1438
+ ```ts
1439
+ // ❌ cada query a mano
1440
+ findAll(@Query("status") status?: string, @Query("clientName") name?: string) {}
1441
+
1442
+ // ✅ el DTO consolidado: ValidationPipe valida, swagger documenta, el tipo es real
1443
+ findAll(@Query() filters: ListLoansDto) {}
1444
+ ```
1445
+
1446
+ `@Query()` sin argumento (el DTO completo) y un único `@Query('id')` son
1447
+ legítimos (`max` configurable). El mensaje trae el playbook de migración:
1448
+ propiedades `?` + `@IsOptional` + validador + `@ApiPropertyOptional`, y
1449
+ `@Transform`/`@Type` para convertir los strings del query al tipo real.
1450
+ Conecta con `nest-dto-requires-validation`: el DTO que crees ya queda
1451
+ vigilado. Solo el `Query`/`ApiQuery` importados de Nest cuentan.
1452
+
1453
+ ### `skapxd/nest-no-result-response`
1454
+
1455
+ El footgun silencioso de mezclar Result con Nest: si un método de un
1456
+ `@Controller` retorna el `Result` crudo, Nest lo serializa tal cual y el
1457
+ cliente recibe `{ ok: false, error: {...} }` con tus internals — tipos de
1458
+ error de dominio, causas, stack traces. Esta regla lo hace imposible:
1459
+
1460
+ ```ts
1461
+ @Controller("users")
1462
+ export class UsersController {
1463
+ // ❌ el envelope completo viaja al cliente
1464
+ @Get(":id")
1465
+ async findOne(@Param("id") id: string): Promise<Result<User, UserError>> {
1466
+ return this.usersService.findOne(id);
1467
+ }
1468
+
1469
+ // ✅ el controller es la frontera: match() traduce
1470
+ @Get(":id")
1471
+ async findOne(@Param("id") id: string): Promise<UserDto> {
1472
+ const user = await this.usersService.findOne(id);
1473
+
1474
+ return match(user)
1475
+ .with({ ok: true }, ({ value }) => toUserDto(value))
1476
+ .with({ ok: false, error: { type: "NOT_FOUND" } }, () => {
1477
+ throw new NotFoundException();
1478
+ })
1479
+ .exhaustive();
1480
+ }
1481
+ }
1482
+ ```
1483
+
1484
+ Es **type-aware**: resuelve el tipo de retorno real del método (anotado o
1485
+ inferido) hasta el `Result` de `@skapxd/result`, así que devolver el Result
1486
+ por indirección tampoco escapa. Solo aplica a clases con `@Controller`
1487
+ (configurable con `controllerDecoratorNames` para decoradores propios); los
1488
+ services retornan Result con orgullo — ese es el dominio.
1489
+
948
1490
  ### `skapxd/no-deep-relative-imports`
949
1491
 
950
1492
  Limita cuántos niveles puede subir un import relativo. Por defecto **prohíbe
@@ -1041,6 +1583,37 @@ al default export, basta mapear el named en el import dinámico:
1041
1583
  const Card = lazy(() => import("./card").then((m) => ({ default: m.Card })));
1042
1584
  ```
1043
1585
 
1586
+ ### `skapxd/no-else`
1587
+
1588
+ El `if` maneja una condición *nombrada*; el `else` maneja "todo lo demás" —
1589
+ un complemento anónimo cuyo significado el lector deduce negando la
1590
+ condición. Es el último rincón donde un camino vive sin etiqueta, y donde la
1591
+ no-exhaustividad se esconde: una cadena `if/else if/else` sobre flags maneja
1592
+ 2 de 4 combinaciones y deja el resto cayendo en un cajón que nadie auditó.
1593
+
1594
+ ```ts
1595
+ // ❌ ¿qué ES el else? el lector lo deduce; el compilador no audita nada
1596
+ if (s === "a") { runA(); } else if (s === "b") { runB(); } else { runC(); }
1597
+
1598
+ // ✅ guards: cada salida declara su condición y termina
1599
+ if (!user) return Result.err({ ... });
1600
+ return Result.ok(buildProfile(user));
1601
+
1602
+ // ✅ match: cada variante nombrada y exhaustividad verificada
1603
+ match(state)
1604
+ .with({ status: "a" }, runA)
1605
+ .with({ status: "b" }, runB)
1606
+ .exhaustive();
1607
+ ```
1608
+
1609
+ Las salidas: **retorno anticipado** para flujo, **ternario simple** para
1610
+ decisiones de valor (los anidados ya los prohíbe `prefer-ts-pattern`), y
1611
+ **`match().exhaustive()`** para variantes. La única fricción real — dos
1612
+ ramas de efectos en medio de una función — se resuelve extrayendo la función
1613
+ que `one-root-function-per-file` ya pedía. Complementa a `no-nested-if`
1614
+ (profundidad) y a `prefer-tagged-union-state` (este ataca la *declaración*
1615
+ del estado sin nombre; `no-else` ataca su *consumo*).
1616
+
1044
1617
  ### `skapxd/no-emoji`
1045
1618
 
1046
1619
  Prohíbe emojis en strings, template literals y texto JSX. El problema no es
@@ -1067,6 +1640,40 @@ eximir archivos completos (fixtures, seeds), usa `allowFilePatterns`:
1067
1640
  }]
1068
1641
  ```
1069
1642
 
1643
+ ### `skapxd/no-runtime-state-guard`
1644
+
1645
+ El compañero de `prefer-tagged-union-state` para el comportamiento: cuando un
1646
+ método protege su estado con una comprobación en runtime, la máquina de
1647
+ estados vive en `if` + `throw` — requiere tests para cada ruta inválida y el
1648
+ compilador no puede ayudar (*make invalid states unrepresentable*):
1649
+
1650
+ ```ts
1651
+ // ❌ el guard en runtime: probable con tests, invisible para el compilador
1652
+ class Socket {
1653
+ private isConnected = false;
1654
+ emit(event: string) {
1655
+ if (!this.isConnected) throw new Error("Cannot emit: not connected");
1656
+ }
1657
+ }
1658
+
1659
+ // ✅ cada estado es un tipo: emit NO EXISTE en el socket desconectado
1660
+ class DisconnectedSocket {
1661
+ connect(): ConnectedSocket { ... } // la transición retorna el estado nuevo
1662
+ }
1663
+ class ConnectedSocket {
1664
+ emit(event: string): void { ... } // sin guard: el compilador lo garantiza
1665
+ disconnect(): DisconnectedSocket { ... }
1666
+ }
1667
+ ```
1668
+
1669
+ (La variante funcional: la unión discriminada de `prefer-tagged-union-state`,
1670
+ consumida con `match()`.) Solo aplica al **estado propio** (`this.<prop>`) en
1671
+ métodos de clase — validar argumentos o inputs externos es otro territorio
1672
+ (DTOs, `Result`). Un `if` sobre `this` que retorna temprano sin lanzar
1673
+ tampoco se toca. Nota la sinergia con `class-properties-require-readonly`:
1674
+ el flag mutable que este guard necesita ya era ilegal — las dos reglas
1675
+ empujan juntas hacia las transiciones que retornan instancias nuevas.
1676
+
1070
1677
  ### `skapxd/no-tunnel-props`
1071
1678
 
1072
1679
  **Ninguna prop viaja más de un nivel.** El contrato de saltos: quien **crea**
@@ -1221,6 +1828,80 @@ métodos se prohíben (por defecto los tres):
1221
1828
  "skapxd/no-promise-chain": ["error", { methods: ["catch"] }]
1222
1829
  ```
1223
1830
 
1831
+ ### `skapxd/prefer-tagged-union-state`
1832
+
1833
+ La regla temática del paquete: el estado inconsistente que motivó todo esto,
1834
+ ahora prohibido en su origen. Detecta las dos formas de la enfermedad:
1835
+
1836
+ **Forma A — el tipo enfermo**: un flag boolean de "en proceso" conviviendo
1837
+ con un campo de error como propiedades independientes. Las combinaciones
1838
+ imposibles (cargando Y con error, error Y con valor) son *representables*:
1839
+
1840
+ ```ts
1841
+ // ❌ 2³ combinaciones; solo 3 tienen sentido
1842
+ type RequestState = { isLoading: boolean; error?: Error; value?: Data };
1843
+
1844
+ // ✅ los estados imposibles no se pueden NI ESCRIBIR
1845
+ type RequestState =
1846
+ | { status: "idle" }
1847
+ | { status: "loading" }
1848
+ | { status: "error"; error: Error }
1849
+ | { status: "ok"; value: Data };
1850
+ ```
1851
+
1852
+ La forma A aplica **igual en el back**: la clase de un job con
1853
+ `private isProcessing = false; private lastError?: Error` es la versión OOP
1854
+ de la máquina repartida, y un schema de Mongoose con `@Prop() isSyncing` +
1855
+ `@Prop() syncError` es la versión más grave — **la inconsistencia se
1856
+ persiste en la base de datos**. La regla revisa tipos, interfaces y cuerpos
1857
+ de clase por igual, con verbos de ambos mundos (`loading`, `submitting`,
1858
+ `deploying`, `migrating`, `retrying`, ...).
1859
+
1860
+ **Forma B — la máquina repartida** (front): varios `useState` que en realidad
1861
+ son una sola máquina de estados. Cada transición toca varios setters y los
1862
+ renders intermedios ven combinaciones imposibles:
1863
+
1864
+ ```ts
1865
+ // ❌ tres setters para una transición: el render del medio ve mentiras
1866
+ const [isLoading, setIsLoading] = useState(false);
1867
+ const [error, setError] = useState<Error | null>(null);
1868
+ const [user, setUser] = useState<User | null>(null);
1869
+
1870
+ // ✅ UN estado, transición atómica, match() exhaustivo
1871
+ const [state, setState] = useState<RequestState>({ status: "idle" });
1872
+ ```
1873
+
1874
+ **Forma C — la transición repartida (evidencia ESTRUCTURAL, sin depender de
1875
+ nombres)**: los setters de `useState` se identifican por *posición en el
1876
+ destructuring* (`const [x, setX] = useState()` — el segundo elemento, se
1877
+ llame como se llame). Si una misma función llama a dos setters distintos,
1878
+ eso **prueba** que esos estados son una sola máquina — entre setter y setter,
1879
+ los renders intermedios ven mentiras:
1880
+
1881
+ ```ts
1882
+ const cargar = (respuesta, fallo) => {
1883
+ setDatos(respuesta); // ❌ dos setters en una transición: una máquina
1884
+ setError(fallo); // repartida, aunque `datos` no se llame "loading"
1885
+ };
1886
+ ```
1887
+
1888
+ Este detector caza lo que los nombres no ven (estados con nombres exóticos o
1889
+ en español ya cubiertos: `cargando`, `procesando`, `fallo`, ...). El filtro
1890
+ de precisión: al menos uno de los estados co-actualizados debe ser
1891
+ loading/error-ish — resetear dos campos independientes de un formulario no
1892
+ es una máquina.
1893
+
1894
+ Sobre la detección por nombres (formas A y B): es deliberadamente el
1895
+ escalón más bajo de evidencia del paquete — para un tipo *declarado* no hay
1896
+ comportamiento que observar y el nombre es la única señal disponible. El
1897
+ **tipo del campo de error no importa** (`Error`, `string`, código numérico,
1898
+ otro boolean — `isSyncing` + `hasError` es la peor forma): la enfermedad es
1899
+ la coexistencia. Los **callbacks** quedan excluidos (`onError?: (e) => void`,
1900
+ miembros de tipo función): un handler no es estado.
1901
+ `loadingPatterns`/`errorPatterns` ajustan las convenciones. Cierra el círculo con el resto del paquete: la unión etiquetada
1902
+ es a los estados lo que `Result` es a los errores, y `prefer-ts-pattern` te
1903
+ espera con el `match().exhaustive()` al otro lado.
1904
+
1224
1905
  ### `skapxd/prefer-ts-pattern`
1225
1906
 
1226
1907
  Prohíbe `switch/case` y ternarios anidados, empujando hacia `match()` de