require-cio-snake-case-properties.js 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. 'use strict'
  2. const SNAKE_CASE_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
  3. function isSnakeCase(name) {
  4. return SNAKE_CASE_RE.test(name)
  5. }
  6. function getStaticKeyName(property) {
  7. if (property.computed) return null
  8. if (property.key.type === 'Identifier') return property.key.name
  9. if (property.key.type === 'Literal' && typeof property.key.value === 'string')
  10. return property.key.value
  11. return null
  12. }
  13. /**
  14. * Check if a node is a call to CustomerIoHandler.updateUserAttributes()
  15. * and return the attributes argument (2nd argument)
  16. */
  17. function getUpdateUserAttributesArg(node) {
  18. if (
  19. node.callee.type === 'MemberExpression' &&
  20. node.callee.object.type === 'Identifier' &&
  21. node.callee.object.name === 'CustomerIoHandler' &&
  22. node.callee.property.name === 'updateUserAttributes' &&
  23. node.arguments[1]?.type === 'ObjectExpression'
  24. ) {
  25. return node.arguments[1]
  26. }
  27. return null
  28. }
  29. /**
  30. * Check if a node is a call to Modules[.promises].hooks.fire('setUserProperties', ...)
  31. * and return the attributes argument (3rd argument)
  32. */
  33. function getSetUserPropertiesArg(node) {
  34. const callee = node.callee
  35. if (callee.type !== 'MemberExpression' || callee.property.name !== 'fire') {
  36. return null
  37. }
  38. // Check first argument is 'setUserProperties'
  39. if (
  40. !node.arguments[0] ||
  41. node.arguments[0].type !== 'Literal' ||
  42. node.arguments[0].value !== 'setUserProperties'
  43. ) {
  44. return null
  45. }
  46. // Match: Modules.hooks.fire or Modules.promises.hooks.fire
  47. const obj = callee.object
  48. if (obj.type === 'MemberExpression' && obj.property.name === 'hooks') {
  49. const parent = obj.object
  50. // Modules.hooks
  51. if (parent.type === 'Identifier' && parent.name === 'Modules') {
  52. if (node.arguments[2]?.type === 'ObjectExpression') {
  53. return node.arguments[2]
  54. }
  55. }
  56. // Modules.promises.hooks
  57. if (
  58. parent.type === 'MemberExpression' &&
  59. parent.property.name === 'promises' &&
  60. parent.object.type === 'Identifier' &&
  61. parent.object.name === 'Modules'
  62. ) {
  63. if (node.arguments[2]?.type === 'ObjectExpression') {
  64. return node.arguments[2]
  65. }
  66. }
  67. }
  68. return null
  69. }
  70. module.exports = {
  71. meta: {
  72. type: 'problem',
  73. docs: {
  74. description:
  75. 'Enforce snake_case for Customer.io user property attribute names',
  76. },
  77. },
  78. create(context) {
  79. return {
  80. CallExpression(node) {
  81. const attrsNode =
  82. getUpdateUserAttributesArg(node) || getSetUserPropertiesArg(node)
  83. if (!attrsNode) return
  84. for (const property of attrsNode.properties) {
  85. if (property.type === 'SpreadElement') continue
  86. const keyName = getStaticKeyName(property)
  87. if (keyName === null) continue // skip computed/dynamic keys
  88. if (!isSnakeCase(keyName)) {
  89. context.report({
  90. node: property.key,
  91. message: `Customer.io attribute '{{name}}' must be in snake_case.`,
  92. data: { name: keyName },
  93. })
  94. }
  95. }
  96. },
  97. }
  98. },
  99. }