flagsmith-nodejs 7.0.3 → 8.0.1

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.
@@ -14,7 +14,7 @@ jobs:
14
14
  build-and-test:
15
15
  strategy:
16
16
  matrix:
17
- node-version: [18.x, 20.x, 22.x]
17
+ node-version: [20.x, 22.x, 24.x]
18
18
  runs-on: ubuntu-latest
19
19
  steps:
20
20
  - uses: actions/checkout@v4
@@ -1 +1 @@
1
- {".":"7.0.3"}
1
+ {".":"8.0.1"}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [8.0.1](https://github.com/Flagsmith/flagsmith-nodejs-client/compare/v8.0.0...v8.0.1) (2026-03-16)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * unwrap TraitConfig values in local evaluation before segment matching ([#252](https://github.com/Flagsmith/flagsmith-nodejs-client/issues/252)) ([4e37994](https://github.com/Flagsmith/flagsmith-nodejs-client/commit/4e37994829bb741665a26aa38d543bebe51231d8))
9
+
10
+ ## [8.0.0](https://github.com/Flagsmith/flagsmith-nodejs-client/compare/v7.0.3...v8.0.0) (2026-02-25)
11
+
12
+
13
+ ### ⚠ BREAKING CHANGES
14
+
15
+ * remove node18 support and update pino ([#220](https://github.com/Flagsmith/flagsmith-nodejs-client/issues/220))
16
+
17
+ ### Bug Fixes
18
+
19
+ * **CVE-2026-1615:** Replace jsonpath with jsonpath-plus to fix security vulnerability ([#247](https://github.com/Flagsmith/flagsmith-nodejs-client/issues/247)) ([56a285c](https://github.com/Flagsmith/flagsmith-nodejs-client/commit/56a285c610a51f1fe3f7d2cd561566d3aa1c6018))
20
+
21
+
22
+ ### Dependency Updates
23
+
24
+ * remove node18 support and update pino ([#220](https://github.com/Flagsmith/flagsmith-nodejs-client/issues/220)) ([a246c06](https://github.com/Flagsmith/flagsmith-nodejs-client/commit/a246c066b062d5897abead34c0f1b8ee1d687d20))
25
+
26
+
27
+ ### Other
28
+
29
+ * Remove amannn/action-semantic-pull-request workflow ([#243](https://github.com/Flagsmith/flagsmith-nodejs-client/issues/243)) ([980728a](https://github.com/Flagsmith/flagsmith-nodejs-client/commit/980728a380518e123e7ce8f6ede98842c915fcae))
30
+
3
31
  ## [7.0.3](https://github.com/Flagsmith/flagsmith-nodejs-client/compare/v7.0.2...v7.0.3) (2026-01-21)
4
32
 
5
33
 
@@ -1,12 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getContextValue = exports.traitsMatchSegmentCondition = exports.getIdentitySegments = void 0;
4
- const jsonpathModule = require("jsonpath");
4
+ const jsonpath_plus_1 = require("jsonpath-plus");
5
5
  const index_js_1 = require("../utils/hashing/index.js");
6
6
  const models_js_1 = require("./models.js");
7
7
  const constants_js_1 = require("./constants.js");
8
- // Handle ESM/CJS interop - jsonpath exports default in ESM
9
- const jsonpath = jsonpathModule.default || jsonpathModule;
10
8
  /**
11
9
  * Returns all segments that the identity belongs to based on segment rules evaluation.
12
10
  *
@@ -100,8 +98,20 @@ function evaluateRuleConditions(ruleType, conditionResults) {
100
98
  return false;
101
99
  }
102
100
  }
101
+ const TRAITS_DOT_PATTERN = /^\$\.identity\.traits\.(.+)$/;
102
+ const TRAITS_BRACKET_PATTERN = /^\$\.identity\.traits\['(.+)'\]$/;
103
+ function extractTraitNameFromPath(property) {
104
+ return TRAITS_DOT_PATTERN.exec(property)?.[1] ?? TRAITS_BRACKET_PATTERN.exec(property)?.[1];
105
+ }
103
106
  function getTraitValue(property, context) {
104
107
  if (property.startsWith('$.')) {
108
+ // Look up $.identity.traits.X and $.identity.traits['X'] paths directly
109
+ // to avoid jsonpath-plus mis-parsing special characters (e.g. $, [, ]) in
110
+ // trait names that appear inside bracket-notation strings.
111
+ const traitName = extractTraitNameFromPath(property);
112
+ if (traitName !== undefined) {
113
+ return context?.identity?.traits?.[traitName];
114
+ }
105
115
  const contextValue = getContextValue(property, context);
106
116
  if (contextValue !== undefined && isPrimitive(contextValue)) {
107
117
  return contextValue;
@@ -135,8 +145,7 @@ function getContextValue(jsonPath, context) {
135
145
  if (!context || !jsonPath?.startsWith('$.'))
136
146
  return undefined;
137
147
  try {
138
- const normalizedPath = normalizeJsonPath(jsonPath);
139
- const results = jsonpath.query(context, normalizedPath);
148
+ const results = (0, jsonpath_plus_1.JSONPath)({ path: jsonPath, json: context });
140
149
  return results.length > 0 ? results[0] : undefined;
141
150
  }
142
151
  catch (error) {
@@ -144,6 +153,3 @@ function getContextValue(jsonPath, context) {
144
153
  }
145
154
  }
146
155
  exports.getContextValue = getContextValue;
147
- function normalizeJsonPath(jsonPath) {
148
- return jsonPath.replace(/\.([^.\[\]]+)$/, "['$1']");
149
- }
@@ -226,7 +226,7 @@ class Flagsmith {
226
226
  const environment = await this.getEnvironment();
227
227
  const identityModel = this.getIdentityModel(environment, identifier, Object.keys(traits || {}).map(key => ({
228
228
  key,
229
- value: traits?.[key]
229
+ value: (0, utils_js_1.isTraitConfig)(traits?.[key]) ? traits[key].value : traits?.[key]
230
230
  })));
231
231
  const context = (0, mappers_js_1.getEvaluationContext)(environment, identityModel);
232
232
  if (!context) {
@@ -379,7 +379,7 @@ class Flagsmith {
379
379
  const environment = await this.getEnvironment();
380
380
  const identityModel = this.getIdentityModel(environment, identifier, Object.keys(traits).map(key => ({
381
381
  key,
382
- value: traits[key]
382
+ value: (0, utils_js_1.isTraitConfig)(traits[key]) ? traits[key].value : traits[key]
383
383
  })));
384
384
  const context = (0, mappers_js_1.getEvaluationContext)(environment, identityModel);
385
385
  if (!context) {
@@ -1,9 +1,7 @@
1
- import * as jsonpathModule from 'jsonpath';
1
+ import { JSONPath } from 'jsonpath-plus';
2
2
  import { getHashedPercentageForObjIds } from '../utils/hashing/index.js';
3
3
  import { SegmentConditionModel } from './models.js';
4
4
  import { IS_NOT_SET, IS_SET, PERCENTAGE_SPLIT } from './constants.js';
5
- // Handle ESM/CJS interop - jsonpath exports default in ESM
6
- const jsonpath = jsonpathModule.default || jsonpathModule;
7
5
  /**
8
6
  * Returns all segments that the identity belongs to based on segment rules evaluation.
9
7
  *
@@ -95,8 +93,20 @@ function evaluateRuleConditions(ruleType, conditionResults) {
95
93
  return false;
96
94
  }
97
95
  }
96
+ const TRAITS_DOT_PATTERN = /^\$\.identity\.traits\.(.+)$/;
97
+ const TRAITS_BRACKET_PATTERN = /^\$\.identity\.traits\['(.+)'\]$/;
98
+ function extractTraitNameFromPath(property) {
99
+ return TRAITS_DOT_PATTERN.exec(property)?.[1] ?? TRAITS_BRACKET_PATTERN.exec(property)?.[1];
100
+ }
98
101
  function getTraitValue(property, context) {
99
102
  if (property.startsWith('$.')) {
103
+ // Look up $.identity.traits.X and $.identity.traits['X'] paths directly
104
+ // to avoid jsonpath-plus mis-parsing special characters (e.g. $, [, ]) in
105
+ // trait names that appear inside bracket-notation strings.
106
+ const traitName = extractTraitNameFromPath(property);
107
+ if (traitName !== undefined) {
108
+ return context?.identity?.traits?.[traitName];
109
+ }
100
110
  const contextValue = getContextValue(property, context);
101
111
  if (contextValue !== undefined && isPrimitive(contextValue)) {
102
112
  return contextValue;
@@ -130,14 +140,10 @@ export function getContextValue(jsonPath, context) {
130
140
  if (!context || !jsonPath?.startsWith('$.'))
131
141
  return undefined;
132
142
  try {
133
- const normalizedPath = normalizeJsonPath(jsonPath);
134
- const results = jsonpath.query(context, normalizedPath);
143
+ const results = JSONPath({ path: jsonPath, json: context });
135
144
  return results.length > 0 ? results[0] : undefined;
136
145
  }
137
146
  catch (error) {
138
147
  return undefined;
139
148
  }
140
149
  }
141
- function normalizeJsonPath(jsonPath) {
142
- return jsonPath.replace(/\.([^.\[\]]+)$/, "['$1']");
143
- }
@@ -3,7 +3,7 @@ import { ANALYTICS_ENDPOINT, AnalyticsProcessor } from './analytics.js';
3
3
  import { FlagsmithAPIError, FlagsmithClientError } from './errors.js';
4
4
  import { Flags } from './models.js';
5
5
  import { EnvironmentDataPollingManager } from './polling_manager.js';
6
- import { Deferred, generateIdentitiesData, getUserAgent, retryFetch } from './utils.js';
6
+ import { Deferred, generateIdentitiesData, getUserAgent, isTraitConfig, retryFetch } from './utils.js';
7
7
  import { SegmentModel, IdentityModel, TraitModel, getEvaluationResult } from '../flagsmith-engine/index.js';
8
8
  import { pino } from 'pino';
9
9
  import { getEvaluationContext } from '../flagsmith-engine/evaluation/evaluationContext/mappers.js';
@@ -216,7 +216,7 @@ export class Flagsmith {
216
216
  const environment = await this.getEnvironment();
217
217
  const identityModel = this.getIdentityModel(environment, identifier, Object.keys(traits || {}).map(key => ({
218
218
  key,
219
- value: traits?.[key]
219
+ value: isTraitConfig(traits?.[key]) ? traits[key].value : traits?.[key]
220
220
  })));
221
221
  const context = getEvaluationContext(environment, identityModel);
222
222
  if (!context) {
@@ -369,7 +369,7 @@ export class Flagsmith {
369
369
  const environment = await this.getEnvironment();
370
370
  const identityModel = this.getIdentityModel(environment, identifier, Object.keys(traits).map(key => ({
371
371
  key,
372
- value: traits[key]
372
+ value: isTraitConfig(traits[key]) ? traits[key].value : traits[key]
373
373
  })));
374
374
  const context = getEvaluationContext(environment, identityModel);
375
375
  if (!context) {
@@ -1,4 +1,4 @@
1
- import * as jsonpathModule from 'jsonpath';
1
+ import { JSONPath } from 'jsonpath-plus';
2
2
  import {
3
3
  GenericEvaluationContext,
4
4
  InSegmentCondition,
@@ -10,9 +10,6 @@ import { getHashedPercentageForObjIds } from '../utils/hashing/index.js';
10
10
  import { SegmentConditionModel } from './models.js';
11
11
  import { IS_NOT_SET, IS_SET, PERCENTAGE_SPLIT } from './constants.js';
12
12
 
13
- // Handle ESM/CJS interop - jsonpath exports default in ESM
14
- const jsonpath = (jsonpathModule as any).default || jsonpathModule;
15
-
16
13
  /**
17
14
  * Returns all segments that the identity belongs to based on segment rules evaluation.
18
15
  *
@@ -140,8 +137,22 @@ function evaluateRuleConditions(ruleType: string, conditionResults: boolean[]):
140
137
  }
141
138
  }
142
139
 
140
+ const TRAITS_DOT_PATTERN = /^\$\.identity\.traits\.(.+)$/;
141
+ const TRAITS_BRACKET_PATTERN = /^\$\.identity\.traits\['(.+)'\]$/;
142
+
143
+ function extractTraitNameFromPath(property: string): string | undefined {
144
+ return TRAITS_DOT_PATTERN.exec(property)?.[1] ?? TRAITS_BRACKET_PATTERN.exec(property)?.[1];
145
+ }
146
+
143
147
  function getTraitValue(property: string, context?: GenericEvaluationContext): any {
144
148
  if (property.startsWith('$.')) {
149
+ // Look up $.identity.traits.X and $.identity.traits['X'] paths directly
150
+ // to avoid jsonpath-plus mis-parsing special characters (e.g. $, [, ]) in
151
+ // trait names that appear inside bracket-notation strings.
152
+ const traitName = extractTraitNameFromPath(property);
153
+ if (traitName !== undefined) {
154
+ return context?.identity?.traits?.[traitName];
155
+ }
145
156
  const contextValue = getContextValue(property, context);
146
157
  if (contextValue !== undefined && isPrimitive(contextValue)) {
147
158
  return contextValue;
@@ -179,14 +190,9 @@ export function getContextValue(jsonPath: string, context?: GenericEvaluationCon
179
190
  if (!context || !jsonPath?.startsWith('$.')) return undefined;
180
191
 
181
192
  try {
182
- const normalizedPath = normalizeJsonPath(jsonPath);
183
- const results = jsonpath.query(context, normalizedPath);
193
+ const results = JSONPath({ path: jsonPath, json: context });
184
194
  return results.length > 0 ? results[0] : undefined;
185
195
  } catch (error) {
186
196
  return undefined;
187
197
  }
188
198
  }
189
-
190
- function normalizeJsonPath(jsonPath: string): string {
191
- return jsonPath.replace(/\.([^.\[\]]+)$/, "['$1']");
192
- }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "flagsmith-nodejs",
3
- "version": "7.0.3",
3
+ "version": "8.0.1",
4
4
  "description": "Flagsmith lets you manage features flags and remote config across web, mobile and server side applications. Deliver true Continuous Integration. Get builds out faster. Control who has access to new features.",
5
5
  "main": "./build/cjs/index.js",
6
6
  "type": "module",
7
7
  "engines": {
8
- "node": ">=18"
8
+ "node": ">=20"
9
9
  },
10
10
  "exports": {
11
11
  "import": "./build/esm/index.js",
@@ -64,8 +64,8 @@
64
64
  "generate-engine-types": "npm run generate-evaluation-result-types && npm run generate-evaluation-context-types"
65
65
  },
66
66
  "dependencies": {
67
- "jsonpath": "^1.1.1",
68
- "pino": "^8.8.0",
67
+ "jsonpath-plus": "^10.4.0",
68
+ "pino": "^10",
69
69
  "semver": "^7.3.7",
70
70
  "undici-types": "^6.19.8"
71
71
  },
package/sdk/index.ts CHANGED
@@ -8,7 +8,13 @@ import { FlagsmithAPIError, FlagsmithClientError } from './errors.js';
8
8
 
9
9
  import { DefaultFlag, Flags } from './models.js';
10
10
  import { EnvironmentDataPollingManager } from './polling_manager.js';
11
- import { Deferred, generateIdentitiesData, getUserAgent, retryFetch } from './utils.js';
11
+ import {
12
+ Deferred,
13
+ generateIdentitiesData,
14
+ getUserAgent,
15
+ isTraitConfig,
16
+ retryFetch
17
+ } from './utils.js';
12
18
  import {
13
19
  SegmentModel,
14
20
  EnvironmentModel,
@@ -275,7 +281,7 @@ export class Flagsmith {
275
281
  identifier,
276
282
  Object.keys(traits || {}).map(key => ({
277
283
  key,
278
- value: traits?.[key]
284
+ value: isTraitConfig(traits?.[key]) ? traits![key].value : traits?.[key]
279
285
  }))
280
286
  );
281
287
 
@@ -474,7 +480,7 @@ export class Flagsmith {
474
480
  identifier,
475
481
  Object.keys(traits).map(key => ({
476
482
  key,
477
- value: traits[key]
483
+ value: isTraitConfig(traits[key]) ? traits[key].value : traits[key]
478
484
  }))
479
485
  );
480
486
 
@@ -345,6 +345,75 @@ describe('getIdentitySegments single segment evaluation', () => {
345
345
  });
346
346
  });
347
347
 
348
+ describe('traitsMatchSegmentCondition with $.identity.traits.* properties', () => {
349
+ const mockContext: EvaluationContext = {
350
+ environment: { key: 'env', name: 'test' },
351
+ identity: {
352
+ key: 'user',
353
+ identifier: 'user@example.com',
354
+ traits: {
355
+ age: 25,
356
+ tamaño: 'grande',
357
+ サイズ: 'medium',
358
+ '[$the.size$]': 'small',
359
+ 'my.foo.bar': 'dotted'
360
+ }
361
+ },
362
+ segments: {},
363
+ features: {}
364
+ };
365
+
366
+ test.each([
367
+ // dot notation – normal trait name
368
+ [{ property: '$.identity.traits.age', operator: 'EQUAL', value: '25' }, true],
369
+ [{ property: '$.identity.traits.age', operator: 'EQUAL', value: '30' }, false],
370
+ // dot notation – unicode trait name
371
+ [{ property: '$.identity.traits.tamaño', operator: 'EQUAL', value: 'grande' }, true],
372
+ [{ property: '$.identity.traits.サイズ', operator: 'EQUAL', value: 'medium' }, true],
373
+ // dot notation – trait name that itself contains dots (everything after $.identity.traits. is the key)
374
+ [{ property: '$.identity.traits.my.foo.bar', operator: 'EQUAL', value: 'dotted' }, true],
375
+ [{ property: '$.identity.traits.my.foo.bar', operator: 'EQUAL', value: 'other' }, false],
376
+ // bracket notation – special characters in trait name that break jsonpath-plus
377
+ [
378
+ { property: "$.identity.traits['[$the.size$]']", operator: 'EQUAL', value: 'small' },
379
+ true
380
+ ],
381
+ [
382
+ { property: "$.identity.traits['[$the.size$]']", operator: 'EQUAL', value: 'large' },
383
+ false
384
+ ],
385
+ // non-existent trait
386
+ [{ property: '$.identity.traits.nonexistent', operator: 'EQUAL', value: 'any' }, false],
387
+ // IS_SET / IS_NOT_SET
388
+ [{ property: '$.identity.traits.age', operator: 'IS_SET', value: null }, true],
389
+ [{ property: '$.identity.traits.nonexistent', operator: 'IS_SET', value: null }, false],
390
+ [{ property: '$.identity.traits.nonexistent', operator: 'IS_NOT_SET', value: null }, true],
391
+ [{ property: '$.identity.traits.age', operator: 'IS_NOT_SET', value: null }, false],
392
+ // IN operator
393
+ [
394
+ {
395
+ property: '$.identity.traits.tamaño',
396
+ operator: CONDITION_OPERATORS.IN,
397
+ value: ['grande', 'pequeño']
398
+ },
399
+ true
400
+ ],
401
+ [
402
+ {
403
+ property: '$.identity.traits.tamaño',
404
+ operator: CONDITION_OPERATORS.IN,
405
+ value: ['pequeño']
406
+ },
407
+ false
408
+ ]
409
+ ] as Array<[SegmentCondition | InSegmentCondition, boolean]>)(
410
+ 'evaluates %j to %s',
411
+ (condition, expected) => {
412
+ expect(traitsMatchSegmentCondition(condition, 'seg', mockContext)).toBe(expected);
413
+ }
414
+ );
415
+ });
416
+
348
417
  describe('getContextValue', () => {
349
418
  const mockContext: EvaluationContext = {
350
419
  environment: {
@@ -354,6 +423,7 @@ describe('getContextValue', () => {
354
423
  identity: {
355
424
  key: 'user-123',
356
425
  identifier: 'user@example.com'
426
+ // intentionally no traits – tests below confirm paths that require traits return undefined
357
427
  },
358
428
  segments: {},
359
429
  features: {}
@@ -371,7 +441,7 @@ describe('getContextValue', () => {
371
441
 
372
442
  // Undefined or invalid cases
373
443
  test.each([
374
- ['$.identity.traits.user_type', 'unsupported nested path'],
444
+ ['$.identity.traits.user_type', 'no traits in context'],
375
445
  ['identity.identifier', 'missing $ prefix'],
376
446
  ['$.invalid.path', 'completely invalid path'],
377
447
  ['$.identity.nonexistent', 'valid structure but missing property'],
@@ -209,6 +209,76 @@ test('test_identity_with_transient_traits', async () => {
209
209
  expect(identityFlags[0].featureName).toBe('some_feature');
210
210
  });
211
211
 
212
+ test('getIdentityFlags local evaluation with plain traits matches segment', async () => {
213
+ const identifier = 'identifier';
214
+ // Plain trait format: age=30 should match segment rule "age LESS_THAN 40"
215
+ const traits = { age: 30 };
216
+
217
+ const flg = flagsmith({
218
+ environmentKey: 'ser.key',
219
+ enableLocalEvaluation: true
220
+ });
221
+
222
+ const flags = await flg.getIdentityFlags(identifier, traits);
223
+
224
+ // Should get segment override value, not the default
225
+ expect(flags.getFeatureValue('some_feature')).toBe('segment_override');
226
+ expect(flags.isFeatureEnabled('some_feature')).toBe(false);
227
+ });
228
+
229
+ test('getIdentityFlags local evaluation with TraitConfig format matches segment', async () => {
230
+ const identifier = 'identifier';
231
+ // TraitConfig format: same trait value wrapped with transient metadata
232
+ const traits = { age: { value: 30, transient: true } };
233
+
234
+ const flg = flagsmith({
235
+ environmentKey: 'ser.key',
236
+ enableLocalEvaluation: true
237
+ });
238
+
239
+ const flags = await flg.getIdentityFlags(identifier, traits);
240
+
241
+ // Should get segment override value — same result as plain trait format
242
+ expect(flags.getFeatureValue('some_feature')).toBe('segment_override');
243
+ expect(flags.isFeatureEnabled('some_feature')).toBe(false);
244
+ });
245
+
246
+ test('getIdentityFlags local evaluation with mixed trait formats matches segment', async () => {
247
+ const identifier = 'identifier';
248
+ // Mix of plain and TraitConfig formats
249
+ const traits = {
250
+ age: { value: 30, transient: true },
251
+ some_other_trait: 'plain_value'
252
+ };
253
+
254
+ const flg = flagsmith({
255
+ environmentKey: 'ser.key',
256
+ enableLocalEvaluation: true
257
+ });
258
+
259
+ const flags = await flg.getIdentityFlags(identifier, traits);
260
+
261
+ // Should get segment override value
262
+ expect(flags.getFeatureValue('some_feature')).toBe('segment_override');
263
+ expect(flags.isFeatureEnabled('some_feature')).toBe(false);
264
+ });
265
+
266
+ test('getIdentitySegments with TraitConfig format matches segment', async () => {
267
+ const identifier = 'identifier';
268
+ // TraitConfig format should work for getIdentitySegments too
269
+ const traits = { age: { value: 30, transient: true } };
270
+
271
+ const flg = flagsmith({
272
+ environmentKey: 'ser.key',
273
+ enableLocalEvaluation: true
274
+ });
275
+
276
+ const segments = await flg.getIdentitySegments(identifier, traits);
277
+
278
+ expect(segments).toHaveLength(1);
279
+ expect(segments[0].name).toBe('regular_segment');
280
+ });
281
+
212
282
  test('getIdentityFlags fails if API call failed and no default flag handler was provided', async () => {
213
283
  const flg = flagsmith({
214
284
  fetch: badFetch
@@ -1,29 +0,0 @@
1
- name: Conventional Commit
2
-
3
- on:
4
- pull_request:
5
- types:
6
- - edited
7
- - opened
8
-
9
- jobs:
10
- conventional-commit:
11
- name: Conventional Commit
12
- runs-on: ubuntu-latest
13
- steps:
14
- - name: Check PR Conventional Commit title
15
- uses: amannn/action-semantic-pull-request@v5
16
- env:
17
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
18
- with:
19
- types: | # mirrors changelog-sections in the /release-please-config.json
20
- feat
21
- fix
22
- infra
23
- ci
24
- docs
25
- deps
26
- perf
27
- refactor
28
- test
29
- chore