@jtl-software/eslint-plugin-posthog 0.1.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.
@@ -0,0 +1,94 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - '**'
7
+ pull_request:
8
+ branches:
9
+ - main
10
+
11
+ jobs:
12
+ test:
13
+ name: Test
14
+ runs-on: ubuntu-latest
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Read .tool-versions
21
+ id: tool-versions
22
+ run: |
23
+ NODE_VERSION=$(grep nodejs .tool-versions | awk '{print $2}')
24
+ PNPM_VERSION=$(grep pnpm .tool-versions | awk '{print $2}')
25
+ echo "node-version=$NODE_VERSION" >> $GITHUB_OUTPUT
26
+ echo "pnpm-version=$PNPM_VERSION" >> $GITHUB_OUTPUT
27
+
28
+ - name: Setup Node.js
29
+ uses: actions/setup-node@v4
30
+ with:
31
+ node-version: ${{ steps.tool-versions.outputs.node-version }}
32
+
33
+ - name: Setup pnpm
34
+ uses: pnpm/action-setup@v4
35
+ with:
36
+ version: ${{ steps.tool-versions.outputs.pnpm-version }}
37
+
38
+ - name: Install dependencies
39
+ run: pnpm install --frozen-lockfile
40
+
41
+ - name: Run tests
42
+ run: pnpm test
43
+
44
+ publish:
45
+ name: Publish to npm
46
+ needs: test
47
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
48
+ runs-on: ubuntu-latest
49
+
50
+ steps:
51
+ - name: Checkout code
52
+ uses: actions/checkout@v4
53
+
54
+ - name: Read .tool-versions
55
+ id: tool-versions
56
+ run: |
57
+ NODE_VERSION=$(grep nodejs .tool-versions | awk '{print $2}')
58
+ PNPM_VERSION=$(grep pnpm .tool-versions | awk '{print $2}')
59
+ echo "node-version=$NODE_VERSION" >> $GITHUB_OUTPUT
60
+ echo "pnpm-version=$PNPM_VERSION" >> $GITHUB_OUTPUT
61
+
62
+ - name: Setup Node.js
63
+ uses: actions/setup-node@v4
64
+ with:
65
+ node-version: ${{ steps.tool-versions.outputs.node-version }}
66
+ registry-url: 'https://registry.npmjs.org'
67
+
68
+ - name: Setup pnpm
69
+ uses: pnpm/action-setup@v4
70
+ with:
71
+ version: ${{ steps.tool-versions.outputs.pnpm-version }}
72
+
73
+ - name: Install dependencies
74
+ run: pnpm install --frozen-lockfile
75
+
76
+ - name: Check if version exists on npm
77
+ id: version-check
78
+ run: |
79
+ PACKAGE_VERSION=$(node -p "require('./package.json').version")
80
+ echo "package-version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
81
+
82
+ if npm view @jtl-software/eslint-plugin-posthog@$PACKAGE_VERSION version 2>/dev/null; then
83
+ echo "should-publish=false" >> $GITHUB_OUTPUT
84
+ echo "Version $PACKAGE_VERSION already exists on npm, skipping publish"
85
+ else
86
+ echo "should-publish=true" >> $GITHUB_OUTPUT
87
+ echo "Version $PACKAGE_VERSION not found on npm, will publish"
88
+ fi
89
+
90
+ - name: Publish to npm
91
+ if: steps.version-check.outputs.should-publish == 'true'
92
+ run: pnpm publish --access public --no-git-checks
93
+ env:
94
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ dist
3
+ coverage
4
+ *.log
5
+ pnpm-lock.yaml
package/.prettierrc ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "plugins": ["@ianvs/prettier-plugin-sort-imports"],
3
+ "printWidth": 90,
4
+ "singleQuote": true,
5
+ "trailingComma": "all",
6
+ "arrowParens": "avoid",
7
+ "useTabs": false,
8
+ "tabWidth": 2
9
+ }
package/.tool-versions ADDED
@@ -0,0 +1,2 @@
1
+ nodejs 22.21.1
2
+ pnpm 10.20.0
package/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # @jtl-software/eslint-plugin-posthog
2
+
3
+ ESLint plugin for enforcing PostHog event tracking best practices.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install --save-dev @jtl-software/eslint-plugin-posthog
9
+ ```
10
+
11
+ ```bash
12
+ yarn add -D @jtl-software/eslint-plugin-posthog
13
+ ```
14
+
15
+ ```bash
16
+ pnpm add -D @jtl-software/eslint-plugin-posthog
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### Flat Config (ESLint 9+)
22
+
23
+ ```js
24
+ // eslint.config.js
25
+ import posthog from '@jtl-software/eslint-plugin-posthog';
26
+
27
+ export default [
28
+ posthog.configs.recommended,
29
+ {
30
+ // Your other config here
31
+ },
32
+ ];
33
+ ```
34
+
35
+ ### Legacy Config (.eslintrc)
36
+
37
+ For ESLint 8 and below, add to your `.eslintrc.js` or `.eslintrc.json`:
38
+
39
+ ```js
40
+ module.exports = {
41
+ plugins: ['@jtl-software/eslint-plugin-posthog'],
42
+ rules: {
43
+ '@jtl-software/posthog/consistent-property-naming': 'error',
44
+ '@jtl-software/posthog/valid-event-names': 'error',
45
+ },
46
+ };
47
+ ```
48
+
49
+ ## Rules
50
+
51
+ | Rule | Description | Severity |
52
+ | ------------------------------------------------------------------------ | -------------------------------------- | -------- |
53
+ | [consistent-property-naming](./docs/rules/consistent-property-naming.md) | Enforce camelCase for property names | error |
54
+ | [valid-event-names](./docs/rules/valid-event-names.md) | Enforce valid event naming conventions | error |
55
+
56
+
57
+ ## Examples
58
+
59
+ ### Before
60
+
61
+ ```js
62
+ // ❌ Multiple issues
63
+ postHog.capture('user clicked button', {
64
+ user_id: '123', // snake_case instead of camelCase
65
+ ButtonName: 'Submit', // PascalCase
66
+ });
67
+ ```
68
+
69
+ ### After
70
+
71
+ ```js
72
+ // ✅ Following best practices
73
+ const EVENTS = {
74
+ BUTTON_CLICKED: 'button_clicked',
75
+ PAGE_VIEWED: 'page_viewed',
76
+ };
77
+
78
+ postHog.capture(EVENTS.BUTTON_CLICKED, {
79
+ userId: '123',
80
+ buttonName: 'Submit',
81
+ page: 'checkout',
82
+ });
83
+
84
+ postHog.capture(EVENTS.PAGE_VIEWED, {
85
+ pagePath: '/checkout',
86
+ referrer: document.referrer,
87
+ });
88
+ ```
89
+
90
+ ## Development
91
+
92
+ ### Running Tests
93
+
94
+ ```bash
95
+ pnpm test
96
+ # or
97
+ pnpm test:watch
98
+ ```
99
+
100
+ ### Testing Locally
101
+
102
+ To test the plugin in your project before publishing:
103
+
104
+ 1. In this directory, run:
105
+
106
+ ```bash
107
+ npm link
108
+ ```
109
+
110
+ 2. In your project directory:
111
+
112
+ ```bash
113
+ npm link @jtl-software/eslint-plugin-posthog
114
+ ```
115
+
116
+ 3. Add the plugin to your ESLint config as shown above
117
+
118
+ ### CI/CD
119
+
120
+ This project uses GitHub Actions for continuous integration and deployment:
121
+
122
+ - **Tests** run automatically on every push and pull request
123
+ - **Publishing to npm** happens automatically when pushing to the `main` branch
124
+
125
+ To set up automatic publishing, add an `NPM_TOKEN` secret to your GitHub repository:
126
+
127
+ 1. Generate an npm access token at https://www.npmjs.com/settings/tokens
128
+ 2. Go to your repository settings on GitHub
129
+ 3. Navigate to Settings → Secrets and variables → Actions
130
+ 4. Add a new repository secret named `NPM_TOKEN` with your npm token
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,44 @@
1
+ # consistent-property-naming
2
+
3
+ Enforce consistent property naming convention (camelCase) in PostHog capture calls.
4
+
5
+ ## Rule Details
6
+
7
+ This rule enforces camelCase naming for all properties passed to `postHog.capture()` calls. Consistent naming conventions make it easier to query and analyze events in PostHog.
8
+
9
+ ## Examples
10
+
11
+ ### ❌ Incorrect
12
+
13
+ ```js
14
+ postHog.capture('user_action', {
15
+ user_id: '123', // snake_case
16
+ ProductName: 'Test', // PascalCase
17
+ 'user-email': 'test@example.com', // kebab-case
18
+ });
19
+ ```
20
+
21
+ ### ✅ Correct
22
+
23
+ ```js
24
+ postHog.capture('user_action', {
25
+ userId: '123',
26
+ productName: 'Test',
27
+ userEmail: 'test@example.com',
28
+ });
29
+ ```
30
+
31
+ ## Why?
32
+
33
+ - **Consistency**: Makes queries and analysis easier
34
+ - **Schema validation**: Aligns with code property naming conventions
35
+ - **Discoverability**: Properties with consistent naming are easier to find
36
+
37
+ ## When Not To Use It
38
+
39
+ If your team has established a different naming convention (e.g., snake_case) across all tracking implementations, you may want to disable this rule.
40
+
41
+ ## Related Rules
42
+
43
+ - [require-event-schema](./require-event-schema.md)
44
+ - [no-literal-event-names](./no-literal-event-names.md)
@@ -0,0 +1,104 @@
1
+ # valid-event-names
2
+
3
+ Enforce valid event naming conventions for PostHog events.
4
+
5
+ ## Rule Details
6
+
7
+ This rule enforces that event names follow PostHog's recommended naming conventions:
8
+
9
+ 1. **snake_case**: Event names must use lowercase with underscores
10
+ 2. **Object-verb pattern**: Events should follow the `[object]_[verb]` format (e.g., `button_clicked`, `user_created`)
11
+ 3. **Verb ending**: Events must end with a verb to describe the action
12
+
13
+ ## Examples
14
+
15
+ ### ❌ Incorrect
16
+
17
+ ```js
18
+ // Not snake_case (camelCase)
19
+ postHog.capture('buttonClicked', { userId: '123' });
20
+
21
+ // Not snake_case (PascalCase)
22
+ postHog.capture('ButtonClicked', { userId: '123' });
23
+
24
+ // Not snake_case (kebab-case)
25
+ postHog.capture('button-clicked', { userId: '123' });
26
+
27
+ // Too short (missing object or verb)
28
+ postHog.capture('clicked', { userId: '123' });
29
+ postHog.capture('button', { userId: '123' });
30
+
31
+ // Doesn't end with verb
32
+ postHog.capture('button_color', { color: 'red' });
33
+ postHog.capture('user_profile', { userId: '123' });
34
+ ```
35
+
36
+ ### ✅ Correct
37
+
38
+ ```js
39
+ // Past tense verbs
40
+ postHog.capture('button_clicked', { userId: '123' });
41
+ postHog.capture('user_created', { userId: '123' });
42
+ postHog.capture('page_viewed', { path: '/home' });
43
+
44
+ // Present participle
45
+ postHog.capture('video_playing', { videoId: '456' });
46
+ postHog.capture('data_loading', { count: 10 });
47
+
48
+ // Present tense
49
+ postHog.capture('button_click', { buttonId: 'submit' });
50
+ postHog.capture('page_view', { path: '/about' });
51
+ postHog.capture('user_login', { method: 'oauth' });
52
+ postHog.capture('form_submit', { formId: 'contact' });
53
+
54
+ // With category prefix (optional)
55
+ postHog.capture('account:settings_updated', { field: 'email' });
56
+ postHog.capture('signup:button_clicked', { step: 1 });
57
+ ```
58
+
59
+ ## Verb Patterns
60
+
61
+ The rule recognizes several verb patterns:
62
+
63
+ ### Endings
64
+
65
+ - **-ed**: Past tense (clicked, created, viewed, updated)
66
+ - **-ing**: Present participle (clicking, creating, viewing)
67
+ - **-e**: Present tense ending in 'e' (create, delete, update)
68
+ - **-ize/-ise**: Verbs (initialize, customize)
69
+ - **-ate**: Verbs (validate, activate)
70
+ - **-ify**: Verbs (notify, verify)
71
+
72
+ ### Common Verbs
73
+
74
+ The rule includes a list of common present-tense verbs like: click, view, submit, open, close, add, remove, start, stop, play, pause, load, save, send, login, logout, etc.
75
+
76
+ ## Category Prefix (Optional)
77
+
78
+ The rule supports PostHog's enhanced framework with optional category prefixes:
79
+
80
+ ```js
81
+ postHog.capture('category:object_action', { ... });
82
+ ```
83
+
84
+ Examples:
85
+
86
+ - `account_settings:password_changed`
87
+ - `signup_flow:form_submitted`
88
+ - `checkout:payment_completed`
89
+
90
+ ## Why?
91
+
92
+ - **Consistency**: Makes events easier to find and filter in PostHog
93
+ - **Best practice**: Follows PostHog's official recommendations
94
+ - **Clarity**: Object-verb pattern clearly describes what happened
95
+ - **Analysis**: Consistent naming enables better querying and grouping
96
+
97
+ ## When Not To Use It
98
+
99
+ If your team has established a different event naming convention and can't migrate, you may want to disable this rule. However, following PostHog's recommendations will make your analytics more maintainable.
100
+
101
+ ## Related Rules
102
+
103
+ - [no-literal-event-names](./no-literal-event-names.md)
104
+ - [consistent-property-naming](./consistent-property-naming.md)
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@jtl-software/eslint-plugin-posthog",
3
+ "version": "0.1.0",
4
+ "description": "ESLint rules for PostHog event tracking best practices",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "keywords": [
8
+ "eslint",
9
+ "eslint-plugin",
10
+ "posthog",
11
+ "analytics",
12
+ "event-tracking"
13
+ ],
14
+ "author": "JTL Platform Team",
15
+ "license": "MIT",
16
+ "peerDependencies": {
17
+ "eslint": "^9.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@eslint/js": "^9.0.0",
21
+ "eslint": "^9.0.0",
22
+ "prettier": "^3.7.4",
23
+ "typescript-eslint": "^8.0.0",
24
+ "vitest": "^3.0.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=18.0.0"
28
+ },
29
+ "scripts": {
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "lint": "eslint src tests",
33
+ "format": "prettier --write .",
34
+ "format:check": "prettier --check ."
35
+ }
36
+ }
package/src/index.js ADDED
@@ -0,0 +1,24 @@
1
+ import consistentPropertyNaming from './rules/consistent-property-naming.js';
2
+ import validEventNames from './rules/valid-event-names.js';
3
+
4
+ const plugin = {
5
+ meta: {
6
+ name: '@jtl-software/eslint-plugin-posthog',
7
+ version: '0.1.0',
8
+ },
9
+ rules: {
10
+ 'consistent-property-naming': consistentPropertyNaming,
11
+ 'valid-event-names': validEventNames,
12
+ },
13
+ configs: {
14
+ recommended: {
15
+ plugins: ['posthog'],
16
+ rules: {
17
+ 'posthog/consistent-property-naming': 'error',
18
+ 'posthog/valid-event-names': 'error',
19
+ },
20
+ },
21
+ },
22
+ };
23
+
24
+ export default plugin;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @fileoverview Enforce consistent property naming convention (camelCase) in PostHog events
3
+ * @author JTL Platform Team
4
+ */
5
+
6
+ export default {
7
+ meta: {
8
+ type: 'suggestion',
9
+ docs: {
10
+ description:
11
+ 'Enforce consistent property naming convention (camelCase) in PostHog capture calls',
12
+ recommended: true,
13
+ },
14
+ messages: {
15
+ notCamelCase: 'Property "{{property}}" should use camelCase naming convention',
16
+ },
17
+ schema: [],
18
+ },
19
+
20
+ create(context) {
21
+ /**
22
+ * Check if a string is in camelCase
23
+ * @param {string} str - The string to check
24
+ * @returns {boolean}
25
+ */
26
+ function isCamelCase(str) {
27
+ // Allow camelCase: starts with lowercase, can contain uppercase letters and numbers
28
+ // No underscores or hyphens allowed
29
+ return /^[a-z][a-zA-Z0-9]*$/.test(str);
30
+ }
31
+
32
+ /**
33
+ * Check if node is a PostHog capture call
34
+ * @param {Object} node - AST node
35
+ * @returns {boolean}
36
+ */
37
+ function isPostHogCapture(node) {
38
+ return (
39
+ node.type === 'CallExpression' &&
40
+ node.callee.type === 'MemberExpression' &&
41
+ node.callee.property.name === 'capture' &&
42
+ node.callee.object.name === 'postHog'
43
+ );
44
+ }
45
+
46
+ return {
47
+ CallExpression(node) {
48
+ if (!isPostHogCapture(node)) {
49
+ return;
50
+ }
51
+
52
+ // Second argument should be the properties object
53
+ const propertiesArg = node.arguments[1];
54
+ if (!propertiesArg || propertiesArg.type !== 'ObjectExpression') {
55
+ return;
56
+ }
57
+
58
+ // Check each property in the object
59
+ propertiesArg.properties.forEach((prop) => {
60
+ if (prop.type === 'Property' && prop.key.type === 'Identifier') {
61
+ const propertyName = prop.key.name;
62
+ if (!isCamelCase(propertyName)) {
63
+ context.report({
64
+ node: prop.key,
65
+ messageId: 'notCamelCase',
66
+ data: {
67
+ property: propertyName,
68
+ },
69
+ });
70
+ }
71
+ }
72
+ });
73
+ },
74
+ };
75
+ },
76
+ };
@@ -0,0 +1,318 @@
1
+ /**
2
+ * @fileoverview Enforce valid event naming conventions for PostHog events
3
+ * @author JTL Platform Team
4
+ */
5
+
6
+ export default {
7
+ meta: {
8
+ type: 'suggestion',
9
+ docs: {
10
+ description: 'Enforce snake_case and object-verb pattern for PostHog event names',
11
+ recommended: true,
12
+ },
13
+ messages: {
14
+ notSnakeCase: 'Event name "{{eventName}}" should use snake_case naming convention',
15
+ missingVerb:
16
+ 'Event name "{{eventName}}" should end with a verb (e.g., "object_clicked", "user_created", "page_view")',
17
+ tooShort:
18
+ 'Event name "{{eventName}}" should follow the [object][verb] pattern with at least two parts',
19
+ },
20
+ schema: [],
21
+ },
22
+
23
+ create(context) {
24
+ /**
25
+ * Check if a string is in snake_case
26
+ * @param {string} str - The string to check
27
+ * @returns {boolean}
28
+ */
29
+ function isSnakeCase(str) {
30
+ // Allow snake_case: lowercase letters, numbers, and underscores
31
+ // Can also start with a category prefix like "category:object_action"
32
+ return /^[a-z][a-z0-9_:]*$/.test(str);
33
+ }
34
+
35
+ /**
36
+ * Check if the last word in an event name looks like a verb
37
+ * @param {string} eventName - The event name to check
38
+ * @returns {boolean}
39
+ */
40
+ function endsWithVerb(eventName) {
41
+ // Remove category prefix if present (e.g., "account:button_clicked" -> "button_clicked")
42
+ const nameWithoutCategory = eventName.includes(':') ? eventName.split(':')[1] : eventName;
43
+
44
+ // Split by underscore and get the last word
45
+ const words = nameWithoutCategory.split('_');
46
+ const lastWord = words[words.length - 1];
47
+
48
+ // Check for common verb patterns
49
+ const verbPatterns = [/ed$/, /ing$/, /ize$/, /ise$/, /ate$/, /ify$/];
50
+
51
+ // Common present tense verbs without specific patterns
52
+ const commonVerbs = [
53
+ 'click',
54
+ 'view',
55
+ 'submit',
56
+ 'open',
57
+ 'close',
58
+ 'add',
59
+ 'remove',
60
+ 'start',
61
+ 'stop',
62
+ 'play',
63
+ 'pause',
64
+ 'load',
65
+ 'save',
66
+ 'send',
67
+ 'get',
68
+ 'post',
69
+ 'put',
70
+ 'patch',
71
+ 'delete',
72
+ 'fetch',
73
+ 'push',
74
+ 'pull',
75
+ 'set',
76
+ 'reset',
77
+ 'clear',
78
+ 'show',
79
+ 'hide',
80
+ 'toggle',
81
+ 'select',
82
+ 'deselect',
83
+ 'check',
84
+ 'uncheck',
85
+ 'enable',
86
+ 'disable',
87
+ 'expand',
88
+ 'collapse',
89
+ 'scroll',
90
+ 'hover',
91
+ 'focus',
92
+ 'blur',
93
+ 'drag',
94
+ 'drop',
95
+ 'upload',
96
+ 'download',
97
+ 'export',
98
+ 'import',
99
+ 'print',
100
+ 'copy',
101
+ 'paste',
102
+ 'cut',
103
+ 'undo',
104
+ 'redo',
105
+ 'refresh',
106
+ 'reload',
107
+ 'login',
108
+ 'logout',
109
+ 'signup',
110
+ 'signin',
111
+ 'signout',
112
+ 'create',
113
+ 'update',
114
+ 'archive',
115
+ 'unarchive',
116
+ 'like',
117
+ 'share',
118
+ 'subscribe',
119
+ 'unsubscribe',
120
+ 'favorite',
121
+ 'unfavorite',
122
+ 'mute',
123
+ 'unmute',
124
+ 'change',
125
+ 'complete',
126
+ 'execute',
127
+ 'terminate',
128
+ ];
129
+
130
+ return (
131
+ verbPatterns.some((pattern) => pattern.test(lastWord)) || commonVerbs.includes(lastWord)
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Check if event name follows [object][verb] pattern
137
+ * @param {string} eventName - The event name to check
138
+ * @returns {boolean}
139
+ */
140
+ function hasObjectVerbPattern(eventName) {
141
+ // Remove category prefix if present
142
+ const nameWithoutCategory = eventName.includes(':') ? eventName.split(':')[1] : eventName;
143
+
144
+ // Should have at least 2 parts (object + verb)
145
+ const parts = nameWithoutCategory.split('_');
146
+ return parts.length >= 2;
147
+ }
148
+
149
+ /**
150
+ * Check if node is a PostHog capture call
151
+ * @param {Object} node - AST node
152
+ * @returns {boolean}
153
+ */
154
+ function isPostHogCapture(node) {
155
+ return (
156
+ node.type === 'CallExpression' &&
157
+ node.callee.type === 'MemberExpression' &&
158
+ node.callee.property.name === 'capture' &&
159
+ node.callee.object.name === 'postHog'
160
+ );
161
+ }
162
+
163
+ // Track identifiers that refer to postHog.capture
164
+ const captureAliases = new Set();
165
+
166
+ /**
167
+ * Check if a CallExpression node is a capture call (direct or aliased)
168
+ * @param {Object} node - AST node
169
+ * @returns {boolean}
170
+ */
171
+ function isCaptureCall(node) {
172
+ if (node.type !== 'CallExpression') {
173
+ return false;
174
+ }
175
+
176
+ // Direct postHog.capture()
177
+ if (isPostHogCapture(node)) {
178
+ return true;
179
+ }
180
+
181
+ // Aliased or imported capture()
182
+ if (node.callee.type === 'Identifier' && captureAliases.has(node.callee.name)) {
183
+ return true;
184
+ }
185
+
186
+ return false;
187
+ }
188
+
189
+ /**
190
+ * Validate an event name argument
191
+ * @param {Object} eventNameArg - The event name argument node
192
+ */
193
+ function validateEventName(eventNameArg) {
194
+ // Only check literal strings (constants will be checked at their definition)
195
+ if (eventNameArg.type === 'Literal' && typeof eventNameArg.value === 'string') {
196
+ const eventName = eventNameArg.value;
197
+
198
+ // Check snake_case
199
+ if (!isSnakeCase(eventName)) {
200
+ context.report({
201
+ node: eventNameArg,
202
+ messageId: 'notSnakeCase',
203
+ data: { eventName },
204
+ });
205
+ return;
206
+ }
207
+
208
+ // Check object-verb pattern (at least 2 parts)
209
+ if (!hasObjectVerbPattern(eventName)) {
210
+ context.report({
211
+ node: eventNameArg,
212
+ messageId: 'tooShort',
213
+ data: { eventName },
214
+ });
215
+ return;
216
+ }
217
+
218
+ // Check that it ends with a verb
219
+ if (!endsWithVerb(eventName)) {
220
+ context.report({
221
+ node: eventNameArg,
222
+ messageId: 'missingVerb',
223
+ data: { eventName },
224
+ });
225
+ }
226
+ }
227
+
228
+ // Also check if it's an Identifier (constant reference)
229
+ if (eventNameArg.type === 'Identifier') {
230
+ // Find the constant definition
231
+ const scope = context.sourceCode.getScope(eventNameArg);
232
+ const variable = scope.variables.find((v) => v.name === eventNameArg.name);
233
+
234
+ if (variable && variable.defs.length > 0) {
235
+ const def = variable.defs[0];
236
+ if (def.node.init && def.node.init.type === 'Literal') {
237
+ const eventName = def.node.init.value;
238
+
239
+ if (typeof eventName === 'string') {
240
+ // Check snake_case
241
+ if (!isSnakeCase(eventName)) {
242
+ context.report({
243
+ node: def.node.init,
244
+ messageId: 'notSnakeCase',
245
+ data: { eventName },
246
+ });
247
+ return;
248
+ }
249
+
250
+ // Check object-verb pattern
251
+ if (!hasObjectVerbPattern(eventName)) {
252
+ context.report({
253
+ node: def.node.init,
254
+ messageId: 'tooShort',
255
+ data: { eventName },
256
+ });
257
+ return;
258
+ }
259
+
260
+ // Check that it ends with a verb
261
+ if (!endsWithVerb(eventName)) {
262
+ context.report({
263
+ node: def.node.init,
264
+ messageId: 'missingVerb',
265
+ data: { eventName },
266
+ });
267
+ }
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ return {
275
+ // Track imports of capture from posthog packages
276
+ ImportDeclaration(node) {
277
+ if (
278
+ node.source.value === 'posthog-js' ||
279
+ node.source.value === 'posthog' ||
280
+ node.source.value.startsWith('@posthog/')
281
+ ) {
282
+ node.specifiers.forEach((specifier) => {
283
+ if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'capture') {
284
+ captureAliases.add(specifier.local.name);
285
+ }
286
+ });
287
+ }
288
+ },
289
+
290
+ // Track variable assignments of postHog.capture
291
+ VariableDeclarator(node) {
292
+ if (
293
+ node.init &&
294
+ node.init.type === 'MemberExpression' &&
295
+ node.init.object.name === 'postHog' &&
296
+ node.init.property.name === 'capture' &&
297
+ node.id.type === 'Identifier'
298
+ ) {
299
+ captureAliases.add(node.id.name);
300
+ }
301
+ },
302
+
303
+ CallExpression(node) {
304
+ if (!isCaptureCall(node)) {
305
+ return;
306
+ }
307
+
308
+ // First argument should be the event name
309
+ const eventNameArg = node.arguments[0];
310
+ if (!eventNameArg) {
311
+ return;
312
+ }
313
+
314
+ validateEventName(eventNameArg);
315
+ },
316
+ };
317
+ },
318
+ };
@@ -0,0 +1,74 @@
1
+ import { RuleTester } from 'eslint';
2
+ import rule from '../../src/rules/consistent-property-naming.js';
3
+
4
+ const ruleTester = new RuleTester({
5
+ languageOptions: {
6
+ ecmaVersion: 2022,
7
+ sourceType: 'module',
8
+ },
9
+ });
10
+
11
+ ruleTester.run('consistent-property-naming', rule, {
12
+ valid: [
13
+ {
14
+ code: `postHog.capture('event_name', { userId: '123', productName: 'Test' })`,
15
+ },
16
+ {
17
+ code: `postHog.capture('event_name', { count: 5, isActive: true })`,
18
+ },
19
+ {
20
+ code: `postHog.capture('event_name', { a: 1, bc: 2, def: 3 })`,
21
+ },
22
+ {
23
+ code: `// Not a PostHog call
24
+ someOtherFunction({ user_id: '123' })`,
25
+ },
26
+ ],
27
+
28
+ invalid: [
29
+ {
30
+ code: `postHog.capture('event_name', { user_id: '123' })`,
31
+ errors: [
32
+ {
33
+ messageId: 'notCamelCase',
34
+ data: { property: 'user_id' },
35
+ },
36
+ ],
37
+ },
38
+ {
39
+ code: `postHog.capture('event_name', { product_name: 'Test', userId: '123' })`,
40
+ errors: [
41
+ {
42
+ messageId: 'notCamelCase',
43
+ data: { property: 'product_name' },
44
+ },
45
+ ],
46
+ },
47
+ {
48
+ code: `postHog.capture('event_name', { UserID: '123', productName: 'Test' })`,
49
+ errors: [
50
+ {
51
+ messageId: 'notCamelCase',
52
+ data: { property: 'UserID' },
53
+ },
54
+ ],
55
+ },
56
+ {
57
+ code: `postHog.capture('event_name', {
58
+ userId: '123',
59
+ product_name: 'Test',
60
+ is_active: true
61
+ })`,
62
+ errors: [
63
+ {
64
+ messageId: 'notCamelCase',
65
+ data: { property: 'product_name' },
66
+ },
67
+ {
68
+ messageId: 'notCamelCase',
69
+ data: { property: 'is_active' },
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ });
@@ -0,0 +1,197 @@
1
+ import { RuleTester } from 'eslint';
2
+ import rule from '../../src/rules/valid-event-names.js';
3
+
4
+ const ruleTester = new RuleTester({
5
+ languageOptions: {
6
+ ecmaVersion: 2022,
7
+ sourceType: 'module',
8
+ },
9
+ });
10
+
11
+ ruleTester.run('valid-event-names', rule, {
12
+ valid: [
13
+ // snake_case with object-verb pattern (past tense)
14
+ {
15
+ code: `postHog.capture('button_clicked', { userId: '123' })`,
16
+ },
17
+ {
18
+ code: `postHog.capture('user_created', { userId: '123' })`,
19
+ },
20
+ {
21
+ code: `postHog.capture('page_viewed', { path: '/home' })`,
22
+ },
23
+ // snake_case with object-verb pattern (present participle)
24
+ {
25
+ code: `postHog.capture('video_playing', { videoId: '456' })`,
26
+ },
27
+ {
28
+ code: `postHog.capture('data_loading', { count: 10 })`,
29
+ },
30
+ // snake_case with object-verb pattern (present tense)
31
+ {
32
+ code: `postHog.capture('button_click', { buttonId: 'submit' })`,
33
+ },
34
+ {
35
+ code: `postHog.capture('page_view', { path: '/about' })`,
36
+ },
37
+ {
38
+ code: `postHog.capture('user_login', { method: 'oauth' })`,
39
+ },
40
+ {
41
+ code: `postHog.capture('form_submit', { formId: 'contact' })`,
42
+ },
43
+ // snake_case with category prefix
44
+ {
45
+ code: `postHog.capture('account:settings_updated', { field: 'email' })`,
46
+ },
47
+ {
48
+ code: `postHog.capture('signup:button_clicked', { step: 1 })`,
49
+ },
50
+ // common verb endings
51
+ {
52
+ code: `postHog.capture('user_initialize', { userId: '123' })`,
53
+ },
54
+ {
55
+ code: `postHog.capture('form_validate', { formId: 'signup' })`,
56
+ },
57
+ {
58
+ code: `postHog.capture('account_activate', { accountId: '789' })`,
59
+ },
60
+ {
61
+ code: `postHog.capture('user_notify', { message: 'test' })`,
62
+ },
63
+ // using constants (won't be checked)
64
+ {
65
+ code: `const EVENT_NAME = 'button_clicked';
66
+ postHog.capture(EVENT_NAME, { userId: '123' })`,
67
+ },
68
+ // not a PostHog call
69
+ {
70
+ code: `someOtherFunction('InvalidName', { data: 'test' })`,
71
+ },
72
+ // aliased postHog.capture
73
+ {
74
+ code: `const capture = postHog.capture;
75
+ capture('button_clicked', { userId: '123' })`,
76
+ },
77
+ // imported capture from posthog-js
78
+ {
79
+ code: `import { capture } from 'posthog-js';
80
+ capture('user_created', { userId: '456' })`,
81
+ },
82
+ ],
83
+
84
+ invalid: [
85
+ // not snake_case (camelCase)
86
+ {
87
+ code: `postHog.capture('buttonClicked', { userId: '123' })`,
88
+ errors: [
89
+ {
90
+ messageId: 'notSnakeCase',
91
+ data: { eventName: 'buttonClicked' },
92
+ },
93
+ ],
94
+ },
95
+ // not snake_case (PascalCase)
96
+ {
97
+ code: `postHog.capture('ButtonClicked', { userId: '123' })`,
98
+ errors: [
99
+ {
100
+ messageId: 'notSnakeCase',
101
+ data: { eventName: 'ButtonClicked' },
102
+ },
103
+ ],
104
+ },
105
+ // not snake_case (kebab-case)
106
+ {
107
+ code: `postHog.capture('button-clicked', { userId: '123' })`,
108
+ errors: [
109
+ {
110
+ messageId: 'notSnakeCase',
111
+ data: { eventName: 'button-clicked' },
112
+ },
113
+ ],
114
+ },
115
+ // too short (missing object or verb)
116
+ {
117
+ code: `postHog.capture('clicked', { userId: '123' })`,
118
+ errors: [
119
+ {
120
+ messageId: 'tooShort',
121
+ data: { eventName: 'clicked' },
122
+ },
123
+ ],
124
+ },
125
+ {
126
+ code: `postHog.capture('button', { userId: '123' })`,
127
+ errors: [
128
+ {
129
+ messageId: 'tooShort',
130
+ data: { eventName: 'button' },
131
+ },
132
+ ],
133
+ },
134
+ // doesn't end with verb
135
+ {
136
+ code: `postHog.capture('button_color', { color: 'red' })`,
137
+ errors: [
138
+ {
139
+ messageId: 'missingVerb',
140
+ data: { eventName: 'button_color' },
141
+ },
142
+ ],
143
+ },
144
+ {
145
+ code: `postHog.capture('user_profile', { userId: '123' })`,
146
+ errors: [
147
+ {
148
+ messageId: 'missingVerb',
149
+ data: { eventName: 'user_profile' },
150
+ },
151
+ ],
152
+ },
153
+ // constant with a bad name
154
+ {
155
+ code: `const EVENT_NAME = 'BadName';
156
+ postHog.capture(EVENT_NAME, { userId: '123' })`,
157
+ errors: [
158
+ {
159
+ messageId: 'notSnakeCase',
160
+ data: { eventName: 'BadName' },
161
+ },
162
+ ],
163
+ },
164
+ {
165
+ code: `const EVENT_NAME = 'button_color';
166
+ postHog.capture(EVENT_NAME, { userId: '123' })`,
167
+ errors: [
168
+ {
169
+ messageId: 'missingVerb',
170
+ data: { eventName: 'button_color' },
171
+ },
172
+ ],
173
+ },
174
+ // aliased postHog.capture with bad event name
175
+ {
176
+ code: `const capture = postHog.capture;
177
+ capture('InvalidName', { data: 'test' })`,
178
+ errors: [
179
+ {
180
+ messageId: 'notSnakeCase',
181
+ data: { eventName: 'InvalidName' },
182
+ },
183
+ ],
184
+ },
185
+ // imported capture with bad event name
186
+ {
187
+ code: `import { capture } from 'posthog-js';
188
+ capture('userClicked', { userId: '123' })`,
189
+ errors: [
190
+ {
191
+ messageId: 'notSnakeCase',
192
+ data: { eventName: 'userClicked' },
193
+ },
194
+ ],
195
+ },
196
+ ],
197
+ });
package/tests/setup.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Vitest test setup
3
+ * Configures ESLint's RuleTester to work with Vitest
4
+ */
5
+
6
+ import { RuleTester } from 'eslint';
7
+ import { describe, it, afterAll } from 'vitest';
8
+
9
+ // Configure RuleTester to work with Vitest
10
+ RuleTester.describe = describe;
11
+ RuleTester.it = it;
12
+ RuleTester.itOnly = it.only;
13
+ RuleTester.afterAll = afterAll;
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ setupFiles: ['./tests/setup.js'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ reporter: ['text', 'json', 'html'],
11
+ exclude: ['node_modules/', 'tests/', '*.config.js'],
12
+ },
13
+ },
14
+ });