feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,354 @@
# @coze-arch/eslint-plugin
A comprehensive ESLint plugin designed for Flow applications, providing essential linting rules for code quality, import management, and Zustand state management best practices.
## Features
### Core Rules
- **Import Management**: Prevent deep relative imports and batch import/export issues
- **Code Quality**: Enforce function length limits, proper error handling, and catch block usage
- **Package Management**: Validate package.json structure and dependencies
- **React/TSX**: Prevent leaked renders and other React-specific issues
### Zustand Rules
- **State Management**: Enforce proper state mutation patterns and store conventions
- **Performance**: Optimize selector usage and prevent unnecessary re-renders
- **Best Practices**: Enforce naming conventions and proper store typing
### Processors
- **JSON Processor**: Custom processor for linting package.json files
## Get Started
### Installation
```bash
# Install the package
rush update
# Or using pnpm in workspace
pnpm add @coze-arch/eslint-plugin@workspace:*
```
### Basic Usage
Add the plugin to your ESLint configuration:
```js
// eslint.config.js
import flowPlugin from '@coze-arch/eslint-plugin';
export default [
{
plugins: {
'@coze-arch': flowPlugin,
},
rules: {
'@coze-arch/no-deep-relative-import': ['error', { max: 4 }],
'@coze-arch/max-line-per-function': ['error', { max: 150 }],
'@coze-arch/tsx-no-leaked-render': 'warn',
},
},
];
```
### Using Recommended Configuration
```js
// eslint.config.js
import flowPlugin from '@coze-arch/eslint-plugin';
export default [
...flowPlugin.configs.recommended,
];
```
### Zustand Rules
```js
// eslint.config.js
import zustandPlugin from '@coze-arch/eslint-plugin/zustand';
export default [
{
plugins: {
'@coze-arch/zustand': zustandPlugin,
},
...zustandPlugin.configs.recommended,
},
];
```
## API Reference
### Core Rules
#### `no-deep-relative-import`
Prevents excessive relative import nesting.
```js
// ❌ Bad (default max: 3)
import something from '../../../deep/path';
// ✅ Good
import something from '../../shallow/path';
```
**Options:**
- `max` (number): Maximum allowed relative path depth (default: 3)
#### `max-line-per-function`
Enforces maximum lines per function.
```js
// ❌ Bad (exceeds limit)
function longFunction() {
// ... 200 lines of code
}
// ✅ Good
function shortFunction() {
// ... less than 150 lines
}
```
**Options:**
- `max` (number): Maximum lines per function (default: 150)
#### `tsx-no-leaked-render`
Prevents leaked renders in TSX components.
```js
// ❌ Bad
{count && <Component />} // count could be 0
// ✅ Good
{count > 0 && <Component />}
{Boolean(count) && <Component />}
```
#### `no-pkg-dir-import`
Prevents importing from package directories.
```js
// ❌ Bad
import something from 'package/src/internal';
// ✅ Good
import something from 'package';
```
#### `use-error-in-catch`
Enforces proper error handling in catch blocks.
```js
// ❌ Bad
try {
doSomething();
} catch (e) {
console.log('error occurred');
}
// ✅ Good
try {
doSomething();
} catch (error) {
console.error('error occurred:', error);
}
```
#### `no-empty-catch`
Prevents empty catch blocks.
```js
// ❌ Bad
try {
doSomething();
} catch (error) {
// empty
}
// ✅ Good
try {
doSomething();
} catch (error) {
console.error(error);
}
```
#### `no-new-error`
Discourages creating new Error instances.
```js
// ❌ Bad
throw new Error('Something went wrong');
// ✅ Good (when configured)
throw createError('Something went wrong');
```
### Zustand Rules
#### `no-state-mutation`
Prevents direct state mutation in Zustand stores.
```js
// ❌ Bad
const state = useStore.getState();
state.count = 5;
// ✅ Good
useStore.setState({ count: 5 });
```
#### `prefer-selector`
Encourages using selectors for state access.
```js
// ❌ Bad
const { count, name } = useStore();
// ✅ Good
const count = useStore(state => state.count);
const name = useStore(state => state.name);
```
#### `store-name-convention`
Enforces naming conventions for stores.
```js
// ❌ Bad
const myStore = create(() => ({}));
// ✅ Good
const useMyStore = create(() => ({}));
```
#### `prefer-shallow`
Encourages using shallow equality for object selections.
```js
// ❌ Bad
const { user, settings } = useStore(state => ({
user: state.user,
settings: state.settings
}));
// ✅ Good
const { user, settings } = useStore(
state => ({ user: state.user, settings: state.settings }),
shallow
);
```
### Package.json Rules
#### `package-require-author`
Ensures package.json has an author field.
```json
{
"name": "my-package",
"author": "developer@example.com"
}
```
#### `package-disallow-deps`
Prevents usage of disallowed dependencies (configurable).
## Development
### Setup
```bash
# Install dependencies
rush update
# Run tests
rushx test
# Run with coverage
rushx test:cov
# Lint code
rushx lint
# Build (no-op for this package)
rushx build
```
### Project Structure
```
src/
├── index.ts # Main plugin entry
├── processors/
│ └── json.ts # JSON processor for package.json
├── rules/ # Core ESLint rules
│ ├── no-deep-relative-import/
│ ├── max-lines-per-function/
│ ├── tsx-no-leaked-render/
│ └── ...
└── zustand/ # Zustand-specific rules
├── index.ts # Zustand plugin entry
└── rules/
├── no-state-mutation/
├── prefer-selector/
└── ...
```
### Adding New Rules
1. Create a new directory under `src/rules/` or `src/zustand/rules/`
2. Implement the rule in `index.ts`
3. Add comprehensive tests in `index.test.ts`
4. Export the rule in the main plugin file
5. Add the rule to recommended configuration if appropriate
### Testing
Tests are written using ESLint's `RuleTester`:
```ts
import { RuleTester } from 'eslint';
import { myRule } from './index';
const ruleTester = new RuleTester();
ruleTester.run('my-rule', myRule, {
valid: [
// Valid code examples
],
invalid: [
// Invalid code examples with expected errors
],
});
```
## Dependencies
### Runtime Dependencies
- `@typescript-eslint/utils` - TypeScript ESLint utilities
- `eslint-module-utils` - ESLint module resolution utilities
- `eslint-rule-composer` - Rule composition utilities
- `eslint-traverse` - AST traversal utilities
- `eslint-utils` - General ESLint utilities
- `semver` - Semantic versioning utilities
### Development Dependencies
- `@typescript-eslint/rule-tester` - Rule testing utilities
- `vitest` - Test runner
- `eslint` - ESLint core
- TypeScript and various ESLint plugins for development
## License
Apache-2.0 License
## Author
fanwenjie.fe@bytedance.com
---
For more information about ESLint plugin development, see the [ESLint Plugin Developer Guide](https://eslint.org/docs/developer-guide/).

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["./dist"]
}
]
}

View File

@@ -0,0 +1,46 @@
const js = require('@eslint/js');
const tsParser = require('@typescript-eslint/parser');
const tsPlugin = require('@typescript-eslint/eslint-plugin');
const importPlugin = require('eslint-plugin-import');
const globals = require('globals');
module.exports = [
{
ignores: ['**/*.json', 'dist'],
},
js.configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
vi: true,
},
},
plugins: {
import: importPlugin,
},
rules: {
'import/order': 'error',
},
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
},
plugins: {
'@typescript-eslint': tsPlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
...tsPlugin.configs.stylistic.rules,
'@typescript-eslint/no-unnecessary-condition': 'off',
'arrow-body-style': 'off',
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/array-type': 'off',
},
},
];

View File

@@ -0,0 +1,49 @@
{
"name": "@coze-arch/eslint-plugin",
"version": "1.0.0",
"description": "eslint plugin for flow app",
"license": "Apache-2.0",
"author": "fanwenjie.fe@bytedance.com",
"exports": {
".": "./src/index.js",
"./zustand": "./src/zustand/index.js"
},
"main": "src/index.js",
"scripts": {
"build": "exit 0",
"dev": "npm run build -- -w",
"lint": "eslint ./src --cache --quiet",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@typescript-eslint/utils": "~8.10.0",
"eslint-module-utils": "~2.8.1",
"eslint-rule-composer": "~0.3.0",
"eslint-traverse": "^1.0.0",
"eslint-utils": "~3.0.0",
"semver": "^7.3.7"
},
"devDependencies": {
"@babel/eslint-parser": "~7.25.8",
"@babel/eslint-plugin": "~7.25.7",
"@eslint/js": "~9.12.0",
"@coze-arch/ts-config": "workspace:*",
"@types/eslint": "~9.6.1",
"@types/estree": "^1.0.1",
"@types/node": "^18",
"@types/semver": "^7.3.4",
"@typescript-eslint/eslint-plugin": "^8.5.0",
"@typescript-eslint/parser": "~8.17.0",
"@typescript-eslint/rule-tester": "~8.10.0",
"@vitest/coverage-v8": "~3.0.5",
"eslint": "~9.12.0",
"eslint-config-prettier": "~9.1.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-prettier": "~5.2.1",
"eslint-plugin-react": "~7.37.1",
"eslint-plugin-unicorn": "48.0.1",
"ts-node": "^10.9.1",
"vitest": "~3.0.5"
}
}

View File

@@ -0,0 +1,8 @@
require('ts-node').register({
transpileOnly: true,
cwd: __dirname,
options: {},
})
const { flowPreset } = require('./index.ts')
module.exports = flowPreset

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { jsonParser } from './processors/json';
import { disallowDepRule } from './rules/package-disallow-deps';
import { noDeepRelativeImportRule } from './rules/no-deep-relative-import';
import { noDuplicatedDepsRule } from './rules/no-duplicated-deps';
import { requireAuthorRule } from './rules/package-require-author';
import { maxLinePerFunctionRule } from './rules/max-lines-per-function';
import { noNewErrorRule } from './rules/no-new-error';
import { noBatchImportOrExportRule } from './rules/no-batch-import-or-export';
import { useErrorInCatch } from './rules/use-error-in-catch';
import { noEmptyCatch } from './rules/no-empty-catch';
import { noPkgDirImport } from './rules/no-pkg-dir-import';
import { tsxNoLeakedRender } from './rules/tsx-no-leaked-render';
export const flowPreset = {
rules: {
'package-require-author': requireAuthorRule,
'package-disallow-deps': disallowDepRule,
'no-deep-relative-import': noDeepRelativeImportRule,
'no-duplicated-deps': noDuplicatedDepsRule,
'max-line-per-function': maxLinePerFunctionRule,
'no-new-error': noNewErrorRule,
'no-batch-import-or-export': noBatchImportOrExportRule,
'no-empty-catch': noEmptyCatch,
'use-error-in-catch': useErrorInCatch,
'no-pkg-dir-import': noPkgDirImport,
'tsx-no-leaked-render': tsxNoLeakedRender,
},
configs: {
recommended: [
{
rules: {
'@coze-arch/tsx-no-leaked-render': 'warn',
'@coze-arch/no-pkg-dir-import': 'error',
'@coze-arch/no-duplicated-deps': 'error',
// 不允许超过 4 层的相对应用
'@coze-arch/no-deep-relative-import': [
'error',
{
max: 4,
},
],
'@coze-arch/package-require-author': 'error',
// 函数代码行不要超过 150
'@coze-arch/max-line-per-function': [
'error',
{
max: 150,
},
],
'@coze-arch/no-new-error': 'off',
'@coze-arch/no-batch-import-or-export': 'error',
'@coze-arch/no-empty-catch': 'error',
'@coze-arch/use-error-in-catch': 'warn',
},
},
{
files: ['package.json'],
processor: '@coze-arch/json-processor',
rules: {
// TODO: 需要重构为直接解析json否则全局规则都会对processor处理后的文件`package.js`生效.
//https://github.com/eslint/json
'@coze-arch/package-require-author': 'error',
'@coze-arch/package-disallow-deps': 'error',
// 关闭prettier规则因为该规则lint package.js存在bug
'prettier/prettier': 'off',
},
},
],
},
processors: {
'json-processor': jsonParser,
},
};

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const prefix = 'module.exports = ';
export const jsonParser = {
_prefix: prefix,
supportsAutofix: false,
preprocess(text: string) {
return [`${prefix}${text}`];
},
postprocess(messages /* , fileName */) {
return messages.reduce((total, next) => {
// disable js rules running on json files
// this becomes too noisey, and splitting js and json
// into separate overrides so neither inherit the other
// is lame
// revisit once https://github.com/eslint/rfcs/pull/9 lands
// return total.concat(next);
return total.concat(
next.filter(error => error.ruleId?.startsWith('@coze-arch/')),
);
}, []);
},
};

View File

@@ -0,0 +1,546 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import { maxLinePerFunctionRule } from './index';
const ruleTester = new RuleTester({});
ruleTester.run('max-lines-per-function', maxLinePerFunctionRule, {
valid: [
// Test code in global scope doesn't count
{
code: 'var x = 5;\nvar x = 2;\n',
options: [{ max: 1 }],
},
// Test single line standalone function
{
code: 'function name() {}',
options: [{ max: 1 }],
},
// Test standalone function with lines of code
{
code: 'function name() {\nvar x = 5;\nvar x = 2;\n}',
options: [{ max: 4 }],
},
// Test inline arrow function
{
code: 'const bar = () => 2',
options: [{ max: 1 }],
},
// Test arrow function
{
code: 'const bar = () => {\nconst x = 2 + 1;\nreturn x;\n}',
options: [{ max: 4 }],
},
// skipBlankLines: false with simple standalone function
{
code: 'function name() {\nvar x = 5;\n\t\n \n\nvar x = 2;\n}',
options: [{ max: 7 }],
},
// single line comment
{
code: "function name() {\nvar x = 5;\n// a comment on it's own line\nvar x = 2; // end of line comment\n}",
options: [{ max: 5 }],
},
// multiple different comment types
{
code: 'function name() {\nvar x = 5;\n/* a \n multi \n line \n comment \n*/\n\nvar x = 2; // end of line comment\n}',
options: [{ max: 10 }],
},
// with multiple different comment types, including trailing and leading whitespace
{
code: 'function name() {\nvar x = 5;\n\t/* a comment with leading whitespace */\n/* a comment with trailing whitespace */\t\t\n\t/* a comment with trailing and leading whitespace */\t\t\n/* a \n multi \n line \n comment \n*/\t\t\n\nvar x = 2; // end of line comment\n}',
options: [{ max: 13 }],
},
// Multiple params on separate lines test
{
code: `function foo(
aaa = 1,
bbb = 2,
ccc = 3
) {
return aaa + bbb + ccc
}`,
options: [{ max: 7 }],
},
// IIFE validity test
{
code: `(
function
()
{
}
)
()`,
options: [{ max: 4 }],
},
{
code: `function parent() {
var x = 0;
function nested() {
var y = 0;
x = 2;
}
if ( x === y ) {
x++;
}
}`,
options: [{ max: 10 }],
},
// Class method validity test
{
code: `class foo {
method() {
let y = 10;
let x = 20;
return y + x;
}
}`,
options: [{ max: 5 }],
},
// IIFEs
{
code: `(function(){
let x = 0;
let y = 0;
let z = x + y;
let foo = {};
return bar;
}());`,
options: [{ max: 7 }],
},
],
invalid: [
// Test simple standalone function is recognized
{
code: 'function name() {\n}',
options: [{ max: 1 }],
errors: [
{
messageId: 'exceed',
data: { name: "function 'name'", lineCount: 2, maxLines: 1 },
},
],
},
// Test anonymous function assigned to variable is recognized
{
code: 'var func = function() {\n}',
options: [{ max: 1 }],
errors: [
{
messageId: 'exceed',
data: { name: "function 'func'", lineCount: 2, maxLines: 1 },
},
],
},
// Test arrow functions are recognized
{
code: 'const bar = () => {\nconst x = 2 + 1;\nreturn x;\n}',
options: [{ max: 3 }],
errors: [
{
messageId: 'exceed',
data: { name: "Arrow function 'bar'", lineCount: 4, maxLines: 3 },
},
],
},
// Test inline arrow functions are recognized
{
code: 'const bar = () =>\n 2',
options: [{ max: 1 }],
errors: [
{
messageId: 'exceed',
data: { name: "Arrow function 'bar'", lineCount: 2, maxLines: 1 },
},
],
},
// Test that option defaults work as expected
{
code: `() => {${'var foo\n'.repeat(150)}}`,
options: [{}],
errors: [
{
messageId: 'exceed',
data: { name: 'Arrow function', lineCount: 151, maxLines: 150 },
},
],
},
// Test skipBlankLines: false
{
code: 'function name() {\nvar x = 5;\n\t\n \n\nvar x = 2;\n}',
options: [{ max: 6 }],
errors: [
{
messageId: 'exceed',
data: { name: "function 'name'", lineCount: 7, maxLines: 6 },
},
],
},
// Test skipBlankLines: false with CRLF line endings
{
code: 'function name() {\r\nvar x = 5;\r\n\t\r\n \r\n\r\nvar x = 2;\r\n}',
options: [{ max: 6 }],
errors: [
{
messageId: 'exceed',
data: { name: "function 'name'", lineCount: 7, maxLines: 6 },
},
],
},
//
{
code: 'function name() {\nvar x = 5;\n\t\n \n\nvar x = 2;\n}',
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: "function 'name'", lineCount: 7, maxLines: 2 },
},
],
},
// with CRLF line endings
{
code: 'function name() {\r\nvar x = 5;\r\n\t\r\n \r\n\r\nvar x = 2;\r\n}',
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: "function 'name'", lineCount: 7, maxLines: 2 },
},
],
},
// for multiple types of comment
{
code: 'function name() { // end of line comment\nvar x = 5; /* mid line comment */\n\t// single line comment taking up whole line\n\t\n \n\nvar x = 2;\n}',
options: [{ max: 6 }],
errors: [
{
messageId: 'exceed',
data: { name: "function 'name'", lineCount: 8, maxLines: 6 },
},
],
},
// Test simple standalone function with params on separate lines
{
code: `function foo(
aaa = 1,
bbb = 2,
ccc = 3
) {
return aaa + bbb + ccc
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: "function 'foo'", lineCount: 7, maxLines: 2 },
},
],
},
// Test IIFE "function" keyword is included in the count
{
code: `(
function
()
{
}
)
()`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: 'function', lineCount: 4, maxLines: 2 },
},
],
},
// Test Generator
{
code: ` function* generator() {
yield 1;
yield 2;
yield 3;
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: {
name: "generator function 'generator'",
lineCount: 5,
maxLines: 2,
},
},
],
},
// Test nested functions are included in it's parent's function count.
{
code: `function parent() {
var x = 0;
function nested() {
var y = 0;
x = 2;
}
if ( x === y ) {
x++;
}
}`,
options: [{ max: 9 }],
errors: [
{
messageId: 'exceed',
data: { name: "function 'parent'", lineCount: 10, maxLines: 9 },
},
],
},
// Test nested functions are included in it's parent's function count.
{
code: `function parent() {
var x = 0;
function nested() {
var y = 0;
x = 2;
}
if ( x === y ) {
x++;
}
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: "function 'parent'", lineCount: 10, maxLines: 2 },
},
{
messageId: 'exceed',
data: { name: "function 'nested'", lineCount: 4, maxLines: 2 },
},
],
},
// Test regular methods are recognized
{
code: `class foo {
method() {
let y = 10;
let x = 20;
return y + x;
}
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: "method 'method'", lineCount: 5, maxLines: 2 },
},
],
},
// Test static methods are recognized
{
code: `class A {
static foo (a) {
return a
}
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: "static method 'foo'", lineCount: 3, maxLines: 2 },
},
],
},
// Test private methods are recognized
{
code: `class A {
#privateMethod() {
return "hello world";
}
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: {
name: 'private method #privateMethod',
lineCount: 3,
maxLines: 2,
},
},
],
},
// Test getters are recognized as properties
{
code: `var obj = {
get
foo
() {
return 1
}
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: "getter 'foo'", lineCount: 5, maxLines: 2 },
},
],
},
// Test setters are recognized as properties
{
code: `var obj = {
set
foo
( val ) {
this._foo = val;
}
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: "setter 'foo'", lineCount: 5, maxLines: 2 },
},
],
},
// Test computed property names
{
code: `class A {
static
[
foo +
bar
]
(a) {
return a
}
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: 'static method', lineCount: 8, maxLines: 2 },
},
],
},
// Test computed property names with TemplateLiteral
{
code: `class A {
static
[
\`s\`
]
() {
}
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: "static method 's'", lineCount: 7, maxLines: 2 },
},
],
},
// Test computed property names with Literal
{
code: `class A {
static
"literal"
() {
}
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: "static method 'literal'", lineCount: 5, maxLines: 2 },
},
],
},
// Test computed property names with null
{
code: `class A {
static
null
() {
}
}`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: "static method 'null'", lineCount: 5, maxLines: 2 },
},
],
},
// Test the IIFEs option
{
code: `(function(){
let x = 0;
let y = 0;
let z = x + y;
let foo = {};
return bar;
}());`,
options: [{ max: 2 }],
errors: [
{
messageId: 'exceed',
data: { name: 'function', lineCount: 7, maxLines: 2 },
},
],
},
],
});

View File

@@ -0,0 +1,274 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Rule } from 'eslint';
const getStaticStringValue = node => {
switch (node.type) {
case 'Literal':
return String(node.value);
case 'TemplateLiteral':
if (node.expressions.length === 0 && node.quasis.length === 1) {
return node.quasis[0].value.cooked;
}
break;
default:
break;
}
return null;
};
/**
*
* @param node
* @returns
* 为什么需要这个判断,对于下面这种函数
* ```
* var obj1 = {
* set
* foo
* ( val: any ) {
* this.foo = val;
* }
* }
*```
* 如果不采用下面这个判断函数判断将得到3实际应该为5. 类似的还有
* ```
* //如果不采用下面这个判断函数判断将得到3实际应该为8
* class A {
static
[
foo +
bar
]
(a) {
return a
}
}
* ```
*/
const isEmbedded = node => {
if (!node.parent) {
return false;
}
if (node !== node.parent.value) {
return false;
}
if (node.parent.type === 'MethodDefinition') {
return true;
}
if (node.parent.type === 'Property') {
return (
node.parent.method === true ||
node.parent.kind === 'get' ||
node.parent.kind === 'set'
);
}
return false;
};
/**
*
* @param node
* @returns function name
* Q为什么不直接用 node.id.value获取函数名称 ?
* A这种方式对于 传统的函数写法没问题,但是对于
* const tips = {
* fun: () => {}
* };
* 或者
* const fun2 = () => {}
* 方式书写函数得到的名称为null所以采取下面这种方式获取
*
*/
const getFunctionNameWithKind = node => {
const { parent } = node;
const tokens: string[] = [];
if (
parent.type === 'MethodDefinition' ||
parent.type === 'PropertyDefinition'
) {
// https://github.com/tc39/proposal-static-class-features
if (parent.static) {
tokens.push('static');
}
if (!parent.computed && parent.key.type === 'PrivateIdentifier') {
tokens.push('private');
}
}
if (node.async) {
tokens.push('async');
}
if (node.generator) {
tokens.push('generator');
}
checkParentType(node, parent, tokens);
return tokens.join(' ');
};
const checkParentType = (node, parent, tokens) => {
if (parent.type === 'Property' || parent.type === 'MethodDefinition') {
if (parent.kind === 'constructor') {
tokens.push('constructor');
return;
}
if (parent.kind === 'get') {
tokens.push('getter');
} else if (parent.kind === 'set') {
tokens.push('setter');
} else {
tokens.push('method');
}
} else if (parent.type === 'PropertyDefinition') {
tokens.push('method');
} else {
if (node.type === 'ArrowFunctionExpression') {
tokens.push('Arrow');
}
// VariableDeclarator
tokens.push('function');
}
getParentNodeName(node, parent, tokens);
};
const getParentNodeName = (node, parent, tokens) => {
if (
parent.type === 'Property' ||
parent.type === 'MethodDefinition' ||
parent.type === 'PropertyDefinition' ||
parent.type === 'CallExpression' ||
parent.type === 'VariableDeclarator'
) {
if (!parent.computed && parent?.key?.type === 'PrivateIdentifier') {
tokens.push(`#${parent.key.name}`);
} else {
const name = getStaticPropertyName(parent);
if (name !== null) {
tokens.push(`'${name}'`);
} else if (node.id) {
tokens.push(`'${node.id.name}'`);
}
}
} else if (node.id) {
tokens.push(`'${node.id.name}'`);
}
};
const getStaticPropertyName = node => {
let prop;
switch (node?.type) {
case 'ChainExpression':
return getStaticPropertyName(node.expression);
case 'Property':
case 'PropertyDefinition':
case 'MethodDefinition':
prop = node.key;
break;
case 'MemberExpression':
prop = node.property;
break;
case 'VariableDeclarator':
prop = node.id;
break;
//TODO CallExpression 场景较为复杂,目前应该没有完全覆盖
case 'CallExpression':
prop = node.callee;
break;
// no default
}
if (prop) {
if (prop.type === 'Identifier' && !node.computed) {
return prop.name;
}
return getStaticStringValue(prop);
}
return null;
};
export const maxLinePerFunctionRule: Rule.RuleModule = {
meta: {
docs: {
description: 'Enforce a maximum number of lines of code in a function',
recommended: false,
},
schema: [
{
type: 'object',
properties: {
max: {
type: 'integer',
},
},
additionalProperties: false,
},
],
messages: {
exceed:
'{{name}} has too many lines ({{lineCount}}). Maximum is {{maxLines}}.',
},
},
create(context) {
const options = context.options[0] || {};
const maxLines = options.max || 150;
function checkFunctionLength(funcNode) {
const node = isEmbedded(funcNode) ? funcNode.parent : funcNode;
// 针对函数声明,函数表达式,箭头函数,函数定义四种类型
if (
node.type === 'FunctionDeclaration' ||
node.type === 'FunctionExpression' ||
node.type === 'ArrowFunctionExpression' ||
node.type === 'MethodDefinition' ||
node.type === 'Property'
) {
const lineCount = node.loc.end.line - node.loc.start.line + 1;
const name = getFunctionNameWithKind(node.value || node);
if (lineCount > maxLines) {
context.report({
node,
messageId: 'exceed',
data: {
name,
lineCount: lineCount.toString(),
maxLines: maxLines.toString(),
},
});
}
}
}
return {
FunctionDeclaration: checkFunctionLength,
FunctionExpression: checkFunctionLength,
ArrowFunctionExpression: checkFunctionLength,
};
},
};

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import { noBatchImportOrExportRule } from './index';
const ruleTester = new RuleTester({});
ruleTester.run('no-batch-import-or-export', noBatchImportOrExportRule, {
valid: [
{ code: 'import { foo } from "someModule"' },
{ code: 'import foo from "someModule"' },
{ code: 'export { foo } from "someModule"' },
],
invalid: [
{
code: 'import * as foo from "someModule"',
errors: [
{
messageId: 'avoidUseBatchImport',
data: { code: 'import * as foo from "someModule"' },
},
],
},
{
code: 'export * from "someModule"',
errors: [
{
messageId: 'avoidUseBatchExport',
data: { code: 'export * from "someModule"' },
},
],
},
{
code: 'export * as foo from "someModule"',
errors: [
{
messageId: 'avoidUseBatchExport',
data: { code: 'export * as foo from "someModule"' },
},
],
},
],
});

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Rule } from 'eslint';
export const noBatchImportOrExportRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'Disable batch import or export.',
},
messages: {
avoidUseBatchExport: 'Avoid use batch export: "{{ code }}".',
avoidUseBatchImport: 'Avoid use batch import: "{{ code }}".',
},
},
create(context) {
return {
ExportAllDeclaration: node => {
context.report({
node,
messageId: 'avoidUseBatchExport',
data: {
code: context.sourceCode.getText(node).toString(),
},
});
},
ImportDeclaration: node => {
node.specifiers.forEach(v => {
if (v.type === 'ImportNamespaceSpecifier') {
context.report({
node,
messageId: 'avoidUseBatchImport',
data: {
code: context.sourceCode.getText(node),
},
});
}
});
},
};
},
};

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import { noDeepRelativeImportRule } from './index';
const ruleTester = new RuleTester({});
ruleTester.run('no-deep-relative-import', noDeepRelativeImportRule, {
valid: [
'import "./abc"',
'import "../abc"',
'import "abc"',
'require("./abc")',
'require("../abc")',
'require("abc")',
'require(123)',
'require(xabc)',
'import("./abc")',
'import("../abc")',
'import("abc")',
'import(123)',
'import(xabc)',
{
code: 'import "../../../abc"',
options: [{ max: 4 }],
},
],
invalid: [
{
code: 'import "../../../abc"',
errors: [
{
messageId: 'max',
data: { max: 3 },
},
],
},
{
code: 'require("../../../abc")',
errors: [
{
messageId: 'max',
data: { max: 3 },
},
],
},
{
code: 'import("../../../abc")',
errors: [
{
messageId: 'max',
data: { max: 3 },
},
],
},
{
code: 'import "../../../../../abc"',
options: [{ max: 4 }],
errors: [
{
messageId: 'max',
data: { max: 4 },
},
],
},
],
});

View File

@@ -0,0 +1,106 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Rule } from 'eslint';
const isTooDeep = (declare: string, maxLevel: number) => {
const match = /^(\.\.\/)+/.exec(declare);
if (match) {
// 3 = '../'.length
const deep = match[0].length / 3;
if (deep >= maxLevel) {
return true;
}
}
return false;
};
export const noDeepRelativeImportRule: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'Detect how deep levels in import/require statments',
recommended: true,
},
schema: [
{
type: 'object',
properties: {
max: {
type: 'integer',
},
},
},
],
messages: {
max: "Don't import module exceed {{max}} times of '../'. You should use some alias to avoid such problem.",
},
},
create(context) {
const { max = 3 } = context.options[0] || {};
return {
ImportDeclaration(node) {
if (typeof node.source.value === 'string') {
const declare = node.source.value.trim();
if (isTooDeep(declare, max)) {
context.report({
node,
messageId: 'max',
data: { max },
});
}
}
},
CallExpression(node) {
if (node.callee.type !== 'Identifier') {
return;
}
if (node.callee.name !== 'require') {
return;
}
if (node.arguments.length !== 1) {
return;
}
const arg = node.arguments[0];
if (arg.type === 'Literal' && typeof arg.value === 'string') {
const declare = arg.value.trim();
if (isTooDeep(declare, max)) {
context.report({
node,
messageId: 'max',
data: { max },
});
}
}
},
ImportExpression(node) {
if (
node.source.type === 'Literal' &&
typeof node.source.value === 'string'
) {
const declare = node.source.value.trim();
if (isTooDeep(declare, max)) {
context.report({
node,
messageId: 'max',
data: { max },
});
}
}
},
};
},
};

View File

@@ -0,0 +1,96 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import { jsonParser } from '../../processors/json';
import { noDuplicatedDepsRule } from './index';
function preprocess(tests) {
for (const type of Object.keys(tests)) {
const item = tests[type];
tests[type] = tests[type].map(item => {
item.code = jsonParser.preprocess(item.code)[0];
if (item.output) {
item.output = jsonParser.preprocess(item.output)[0];
}
return item;
});
tests[type] = item;
}
return tests;
}
const ruleTester = new RuleTester();
ruleTester.run(
'no-duplicated-deps',
noDuplicatedDepsRule,
preprocess({
valid: [
{
code: '{}',
filename: 'xx/package.json',
},
{
code: JSON.stringify({ dependencies: {} }),
filename: 'xx/package.json',
},
{
code: JSON.stringify({ dependencies: {}, devDependencies: {} }),
filename: 'xx/package.json',
},
{
code: JSON.stringify({
dependencies: { a: '0.0.1', b: '1.0.0' },
devDependencies: { c: '1.0.0' },
}),
filename: 'xx/package.json',
},
],
invalid: [
{
code: JSON.stringify({
dependencies: { a: '0.0.1' },
devDependencies: { a: '1.0.0' },
}),
filename: 'xx/package.json',
errors: [
{
messageId: 'no-duplicated',
data: { depName: 'a' },
},
],
},
{
code: JSON.stringify({
dependencies: { a: '0.0.1', b: '0.1.1', c: '0.1.0' },
devDependencies: { a: '1.0.0', b: '0.1.1' },
}),
filename: 'xx/package.json',
errors: [
{
messageId: 'no-duplicated',
data: { depName: 'a' },
},
{
messageId: 'no-duplicated',
data: { depName: 'b' },
},
],
},
],
}),
);

View File

@@ -0,0 +1,71 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import path from 'path';
import type { Rule } from 'eslint';
export const noDuplicatedDepsRule: Rule.RuleModule = {
meta: {
docs: {
description: "Don't repeat deps in package.json",
},
messages: {
'no-duplicated': '发现重复声明的依赖:{{depName}},请更正。',
},
},
create(context) {
const filename = context.getFilename();
if (path.basename(filename) !== 'package.json') {
return {};
}
return {
AssignmentExpression(node) {
const json = node.right;
const { properties } = json as any;
if (!properties) {
return;
}
// 对比 dependencies 与 devDependencies 之间是否存在重复依赖
const dependencies = properties.find(
p => p.key.value === 'dependencies',
);
const devDependencies = properties.find(
p => p.key.value === 'devDependencies',
);
if (!dependencies || !devDependencies) {
return;
}
const depValue = dependencies.value.properties;
const devDepValue = devDependencies.value.properties;
depValue.forEach(dep => {
const duplicated = devDepValue.find(
d => d.key.value === dep.key.value,
);
if (duplicated) {
context.report({
node: dep,
messageId: 'no-duplicated',
data: { depName: duplicated.key.value },
});
}
});
},
};
},
};

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import { noEmptyCatch } from './index';
const ruleTester = new RuleTester({});
ruleTester.run('no-empty-catch', noEmptyCatch, {
valid: ['try{ foo }catch(e){ console.log(e) }', 'try{ foo }catch(e){ bar }'],
invalid: [
{
code: 'try{ foo }catch(e){ /* */ }',
errors: [
{
messageId: 'no-empty',
},
],
},
{
code: 'try{ foo }catch(e){}',
errors: [
{
messageId: 'no-empty',
},
],
},
{
code: `try{ foo }catch(e){
//
}`,
errors: [
{
messageId: 'no-empty',
},
],
},
],
});

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Rule } from 'eslint';
export const noEmptyCatch: Rule.RuleModule = {
meta: {
docs: {
description: 'Catch error block should not be empty.',
},
messages: {
'no-empty':
'Catch 代码块中不可为空,否则可能导致错误信息没有得到有效关注',
},
},
create(context) {
return {
CatchClause(node) {
for (const statement of node.body.body) {
if (
!['EmptyStatement', 'CommentBlock', 'CommentLine'].includes(
statement.type,
)
) {
return;
}
}
context.report({
node,
messageId: 'no-empty',
});
},
};
},
};

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import { noNewErrorRule } from './index';
const ruleTester = new RuleTester({});
ruleTester.run('no-new-error', noNewErrorRule, {
valid: [
{
code: `(function(){
class CustomError extends Error {
constructor(eventName, msg) {
super(msg);
this.eventName = eventName;
this.msg = msg;
this.name = 'CustomError';
}
};
new CustomError('copy_error', 'empty copy');
})();`,
},
],
invalid: [
{
code: 'throw new Error("error message")',
output: 'throw new CustomError(\'normal_error\', "error message")',
errors: [
{
messageId: 'no-new-error',
data: { name: 'new Error', lineCount: 1, maxLines: 1 },
},
],
},
],
});

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Rule } from 'eslint';
export const noNewErrorRule: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: "Don't use new Error()",
},
fixable: 'code',
messages: {
'no-new-error': 'found use new Error()',
},
},
create(context) {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
NewExpression(node) {
if (node.callee.type === 'Identifier' && node.callee.name === 'Error') {
context.report({
node,
messageId: 'no-new-error',
fix(fixer) {
const args = node.arguments.map(arg => context.sourceCode.getText(arg)).join(',') || '\'custom error\'';
return fixer.replaceText(node, `new CustomError('normal_error', ${args})`);
},
});
}
},
};
},
};

View File

@@ -0,0 +1,118 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import path, { relative } from 'path';
import { Rule } from 'eslint';
import readPkgUp from 'eslint-module-utils/readPkgUp';
import resolve from 'eslint-module-utils/resolve';
import { exportPathMatch } from './utils';
export const noPkgDirImport: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'limit import package directory directly',
},
messages: {
invalidSubpath:
'subPath `{{ subPath }}` is NOT exported in `{{ pkg }}`, you can config the `exports` fields in package.json',
noExportsCfg:
"NO `exports` fields config in `{{ pkg }}` package.json, you can't import by subPath ",
},
},
create(context) {
return {
ImportDeclaration(node) {
const importPath = `${node.source.value}`;
const modulePath = resolve(importPath, context);
if (!modulePath) {
// 解析不到的情况,暂不处理
return;
}
const { pkg, path: importPkgPath } = readPkgUp({
cwd: modulePath,
}) as any;
const { path: currentPkgPath } = readPkgUp({
cwd: context.filename,
}) as any;
if (!pkg.name) {
return;
}
// 本地link会解析到node_modules目录需要拿到pkg name再次解析。
const moduleRealPath = resolve(pkg.name, context);
if (
// 包名称就是引用路径
pkg.name === importPath ||
// 解析到其他包,如@type
!importPath.startsWith(pkg.name) ||
// 解析到自己包的文件
currentPkgPath === importPkgPath ||
!moduleRealPath ||
moduleRealPath.includes('node_modules')
) {
return;
}
if (!pkg.exports) {
context.report({
messageId: 'noExportsCfg',
data: {
pkg: pkg.name,
},
// @ts-expect-error -- linter-disable-autofix
loc: node.loc,
});
} else if (pkg.exports) {
if (typeof pkg.exports === 'string') {
context.report({
messageId: 'noExportsCfg',
data: {
pkg: pkg.name,
},
// @ts-expect-error -- linter-disable-autofix
loc: node.loc,
});
return;
}
const validSubPath = Object.keys(pkg.exports);
if (
!validSubPath.some(p => {
const pkgExportPath = path.join(pkg.name, p);
return exportPathMatch(importPath, pkgExportPath);
})
) {
const subPath = relative(pkg.name, importPath);
context.report({
messageId: 'invalidSubpath',
data: {
subPath,
pkg: pkg.name,
},
// @ts-expect-error -- linter-disable-autofix
loc: node.loc,
});
}
}
},
};
},
};

View File

@@ -0,0 +1,247 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import resolve from 'eslint-module-utils/resolve';
import readPkgUp from 'eslint-module-utils/readPkgUp';
import { noPkgDirImport } from '../index';
const ruleTester = new RuleTester({});
vi.mock('eslint-module-utils/resolve', () => ({
default: vi.fn(),
}));
vi.mock('eslint-module-utils/readPkgUp', () => ({
default: vi.fn(),
}));
const validCases = [
{
code: 'import "xxx"',
modulePath: undefined, // modulePath 为 空
moduleRealPath: undefined,
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: '',
exports: {},
},
},
{
code: "import pkg from 'some/pkg';",
modulePath: 'path/to/module',
moduleRealPath: 'path/to/module',
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: 'some/pkg', // 包名称与引用路径相同
exports: {},
},
},
{
code: "import pkg from 'some/pkg';",
modulePath: 'path/to/module',
moduleRealPath: 'path/to/module',
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: undefined, // 解析到不规范配置的package.json
},
},
{
code: "import pkg from 'pkg';",
modulePath: 'path/to/module',
moduleRealPath: 'path/to/module',
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: '@types/pkg', // 解析到类型包
exports: {},
},
},
{
code: "import pkg from 'pkg';",
modulePath: 'path/to/module',
moduleRealPath: 'path/to/module',
importPkgPath: 'path/to/same/pkg', // 相同路径
currentPkgPath: 'path/to/same/pkg',
pkg: {
name: '@types/pkg',
exports: {},
},
},
{
code: "import pkg from 'pkg';",
modulePath: 'path/to/module',
moduleRealPath: undefined,
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: 'pkg',
exports: {},
},
},
{
code: "import pkg from 'pkg';",
modulePath: 'path/to/module',
moduleRealPath: 'path/to/node_modules/pkg', // 解析到node_modules
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: 'pkg',
exports: {},
},
},
{
code: "import pkg from 'pkg/subPath';",
modulePath: 'path/to/pkg',
moduleRealPath: 'path/to/pkg',
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: 'pkg',
exports: { subPath: './subPath' },
},
},
{
code: "import pkg from 'pkg/sub/path';",
modulePath: 'path/to/pkg',
moduleRealPath: 'path/to/pkg',
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: 'pkg',
exports: { 'sub/*': './subPath' },
},
},
].map(c => {
vi.mocked(resolve).mockReturnValueOnce(c.modulePath);
if (!c.modulePath) {
return {
code: c.code,
// TODO: 避免eslint duplication检测。可能需要改为其他方式
settings: c,
};
}
if (c.pkg.name) {
vi.mocked(resolve).mockReturnValueOnce(c.moduleRealPath);
}
vi.mocked(readPkgUp)
.mockReturnValueOnce({
pkg: c.pkg,
path: c.importPkgPath,
})
.mockReturnValueOnce({
path: c.currentPkgPath,
});
return {
code: c.code,
settings: c,
};
});
const invalidCases = [
{
code: "import pkg from 'pkg/subPath';",
modulePath: 'path/to/pkg',
moduleRealPath: 'path/to/pkg',
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: 'pkg',
exports: undefined, // 为空
},
messageId: 'noExportsCfg',
},
{
code: "import pkg from 'pkg/subPath';",
modulePath: 'path/to/pkg',
moduleRealPath: 'path/to/pkg',
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: 'pkg',
exports: 'main.js', // isString
},
messageId: 'noExportsCfg',
},
{
code: "import pkg from 'pkg/subPath';",
modulePath: 'path/to/pkg',
moduleRealPath: 'path/to/pkg',
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: 'pkg',
exports: { otherPath: 'otherPath' },
},
messageId: 'invalidSubpath',
},
{
code: "import pkg from 'pkg/sub/path';",
modulePath: 'path/to/pkg',
moduleRealPath: 'path/to/pkg',
importPkgPath: 'path/to/import/pkg',
currentPkgPath: 'path/to/current/pkg',
pkg: {
name: 'pkg',
exports: {
sub: './sub',
},
},
messageId: 'invalidSubpath',
},
].map(c => {
vi.mocked(resolve).mockReturnValueOnce(c.modulePath);
if (!c.modulePath) {
return {
settings: c,
code: c.code,
errors: [],
};
}
vi.mocked(resolve).mockReturnValueOnce(c.moduleRealPath);
vi.mocked(readPkgUp)
.mockReturnValueOnce({
pkg: c.pkg,
path: c.importPkgPath,
})
.mockReturnValueOnce({
path: c.currentPkgPath,
});
return {
settings: c,
code: c.code,
errors: [
{
messageId: c.messageId,
},
],
};
});
ruleTester.run('no-pkg-dir-import', noPkgDirImport, {
valid: [...validCases],
invalid: [...invalidCases],
});

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { exportPathMatch } from '../utils';
describe('exportPathMatch', () => {
it.each([
['./foo', './foo'],
['./foo.js', './*'],
['./foo.js', './*.js'],
['./foo/baz', './foo/*'],
['./foo/baz/baz.js', './foo/*'],
])(
'import path is %s, export path is %s, should be matched',
(importPath, exportPath) => {
expect(exportPathMatch(importPath, exportPath)).toBe(true);
},
);
it.each([
['./foo', './bar'],
['./foo.js', './*.ts'],
['./foo.js', './foo.ts'],
['./baz/bar', './foo/*'],
['./foo/bar/baz.js', './foo/*.js'],
])(
'import path is %s, export path is %s, should NOT be matched',
(importPath, exportPath) => {
expect(exportPathMatch(importPath, exportPath)).toBe(false);
},
);
});

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import path from 'path';
export function exportPathMatch(importPath: string, pkgExportPath: string) {
if (importPath === pkgExportPath) {
return true;
}
const pkgExportBasename = path.basename(pkgExportPath);
if (importPath.startsWith(path.dirname(pkgExportPath))) {
if (pkgExportBasename === '*') {
return true;
}
if (path.dirname(importPath) === path.dirname(pkgExportPath)) {
return pkgExportBasename === `*${path.extname(importPath)}`;
}
}
return false;
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import { jsonParser } from '../../processors/json';
import { disallowDepRule } from './index';
const ruleTester = new RuleTester({});
function preprocess(tests) {
for (const type of Object.keys(tests)) {
const item = tests[type];
tests[type] = tests[type].map(item => {
item.code = jsonParser.preprocess(item.code)[0];
if (item.output) {
item.output = jsonParser.preprocess(item.output)[0];
}
return item;
});
tests[type] = item;
}
return tests;
}
ruleTester.run(
'package-disallow-deps',
disallowDepRule,
preprocess({
valid: [
{
code: JSON.stringify({
dependencies: {
react: '^16.0.0',
},
}),
filename: 'xx/package.json',
},
{
code: JSON.stringify({}),
filename: 'xx/package.json',
},
],
invalid: [
{
code: JSON.stringify({
dependencies: {
react: '^16.0.0',
},
}),
filename: 'xx/package.json',
options: [['react']],
errors: [
{
messageId: 'disallowDep',
data: { dependence: 'react', tips: '' },
},
],
},
{
code: JSON.stringify({
dependencies: {
react: '^16.0.0',
},
}),
filename: 'xx/package.json',
options: [[['react', '<17', 'abc']]],
errors: [
{
messageId: 'disallowVersion',
data: {
dependence: 'react',
version: '^16.0.0',
blockVersion: '<17',
tips: 'abc',
},
},
],
},
{
code: JSON.stringify({
dependencies: {
react: '^16.0.0',
'react-dom': '^16',
},
}),
filename: 'xx/package.json',
options: [[['react', '<17'], 'react-dom']],
errors: [
{
messageId: 'disallowVersion',
data: {
dependence: 'react',
version: '^16.0.0',
tips: '',
blockVersion: '<17',
},
},
{
messageId: 'disallowDep',
data: { dependence: 'react-dom', tips: '' },
},
],
},
{
code: JSON.stringify({
dependencies: {
react: '^16.0.0',
},
devDependencies: {
'react-dom': '^16',
},
}),
filename: 'xx/package.json',
options: [[['react', '<17'], 'react-dom']],
errors: [
{
messageId: 'disallowVersion',
data: {
dependence: 'react',
version: '^16.0.0',
blockVersion: '<17',
tips: '',
},
},
{
messageId: 'disallowDep',
data: { dependence: 'react-dom', tips: '' },
},
],
},
],
}),
);

View File

@@ -0,0 +1,100 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import path from 'path';
import type { Rule } from 'eslint';
import semver from 'semver';
type RuleOptions = Array<string | [string, string]>;
export const disallowDepRule: Rule.RuleModule = {
meta: {
docs: {
description: '禁止使用某些 npm 包',
},
messages: {
disallowDep:
"monorepo 内禁止使用 '{{dependence}}',建议寻找同类 package 替换.\n {{ tips }}",
disallowVersion:
"monorepo 内禁止使用 '{{dependence}}@{{version}}' 版本,请替换为 {{blockVersion}} 之外的版本号",
},
schema: [
{
type: 'array',
},
],
},
create(context) {
const filename = context.getFilename();
if (path.basename(filename) !== 'package.json') {
return {};
}
const blocklist = context.options[0] as RuleOptions;
if (!Array.isArray(blocklist)) {
return {};
}
const normalizeBlocklist = blocklist.map(r =>
typeof r === 'string' ? [r] : r,
);
const detect = (dep: string, version: string, node) => {
const definition = normalizeBlocklist.find(r => r[0] === dep);
if (!definition) {
return;
}
const [, blockVersion, tips] = definition;
// 没有提供 version 参数,判定为不允许所有版本号
if (typeof blockVersion !== 'string' || blockVersion.length <= 0) {
context.report({
node,
messageId: 'disallowDep',
data: {
dependence: dep,
tips: tips || '',
},
});
} else if (semver.intersects(version, blockVersion)) {
context.report({
node,
messageId: 'disallowVersion',
data: {
dependence: dep,
blockVersion,
version,
tips: tips || '',
},
});
}
};
return {
AssignmentExpression(node) {
const json = node.right;
const depProps = ['devDependencies', 'dependencies'];
(json as any).properties
.filter(r => depProps.includes(r.key.value))
.forEach(r => {
const props = r.value.properties;
props.forEach(p => {
const dep = p.key.value;
const version = p.value.value;
detect(dep, version, p);
});
});
},
};
},
};

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import path from 'path';
import type { Rule } from 'eslint';
// cp-disable-next-line
const isBytedancer = name => name.endsWith('@bytedance.com');
export const requireAuthorRule: Rule.RuleModule = {
meta: {
docs: {
description: 'validate author & maintainer property in package.json',
},
messages: {
requireAuthor:
'package.json 文件必须提供 author 字段,这有助于正确生成 CODEOWNER 文件,帮助正确指定代码 reviewer',
authorShouldBeBytedancer:
// cp-disable-next-line
'package.json 文件的 author 字段值应该为 `@bytedance.com` 结尾的邮箱名',
maintainerShouldBeBytedancers:
// cp-disable-next-line
'package.json 文件的 maintainers 字段值应该为 `@bytedance.com` 结尾的邮箱名数组',
},
},
create(context) {
const filename = context.getFilename();
if (path.basename(filename) !== 'package.json') {
return {};
}
return {
AssignmentExpression(node) {
const json = node.right;
const authorProp = (json as any).properties.find(
p => p.key.value === 'author',
);
if (!authorProp) {
context.report({
node: json,
messageId: 'requireAuthor',
});
} else {
const authorValue = authorProp.value;
if (!isBytedancer(authorValue.value)) {
context.report({
node: authorValue,
messageId: 'authorShouldBeBytedancer',
data: { author: authorValue.value },
});
}
}
const maintainerProp = (json as any).properties.find(
p => p.key.value === 'maintainers',
);
if (maintainerProp) {
const maintainers = maintainerProp.value;
if (maintainers.elements?.some(p => !isBytedancer(p.value))) {
context.report({
node: maintainers,
messageId: 'maintainerShouldBeBytedancers',
});
}
}
},
};
},
};

View File

@@ -0,0 +1,6 @@
{
"compilerOptions": {
"strict": true
},
"include": ["react.tsx"]
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import path from 'path';
import { RuleTester } from 'eslint';
import parser from '@typescript-eslint/parser';
import { tsxNoLeakedRender } from '.';
const ruleTester = new RuleTester({
languageOptions: {
parser,
parserOptions: {
tsconfigRootDir: path.resolve(__dirname, './fixture'),
project: path.resolve(__dirname, './fixture/tsconfig.json'),
ecmaFeatures: {
jsx: true,
},
},
},
});
ruleTester.run('tsx-no-leaked-render', tsxNoLeakedRender, {
valid: [
{
code: 'const Foo = (isBar: string) => (<div data-bar={ isBar && "bar" } />);',
filename: 'react.tsx',
},
],
invalid: [],
});

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ruleComposer from 'eslint-rule-composer';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import reactPlugin from 'eslint-plugin-react';
const originRule = reactPlugin.rules['jsx-no-leaked-render'];
// 扩展react/jsx-no-leaked-render。增加判断 「&&」 表达式左边为 boolean 、 null 、 undefined TS类型则不报错。
export const tsxNoLeakedRender = ruleComposer.filterReports(
originRule,
problem => {
const { parent } = problem.node;
// 如果表达式是用于jsx属性则不需要修复。 如 <Comp prop={ { foo: 1 } && obj } />
if (
parent?.type === AST_NODE_TYPES.JSXExpressionContainer &&
parent?.parent?.type === AST_NODE_TYPES.JSXAttribute
) {
return false;
}
return true;
},
);

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import { useErrorInCatch } from './index';
const ruleTester = new RuleTester({});
ruleTester.run('use-error-in-catch', useErrorInCatch, {
valid: ['try{ foo }catch(e){ console.log(e) }'],
invalid: [
{
code: 'try{ foo }catch(error){ bar }',
errors: [
{
messageId: 'use-error',
data: { paramName: 'error' },
},
],
},
{
code: 'try{ foo }catch(e){}',
errors: [
{
messageId: 'use-error',
data: { paramName: 'e' },
},
],
},
{
code: 'try{ foo }catch(e){console.log(c)}',
errors: [
{
messageId: 'use-error',
data: { paramName: 'e' },
},
],
},
],
});

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type Rule } from 'eslint';
import traverse from 'eslint-traverse';
export const useErrorInCatch: Rule.RuleModule = {
meta: {
docs: {
description: 'Use error in catch block',
},
messages: {
'use-error':
'Catch 中应该对捕获到的 "{{paramName}}" 做一些处理,不可直接忽略',
},
},
create(context: Rule.RuleContext) {
return {
CatchClause(node) {
const errorParam = (node.param as { name: string })?.name;
let hasUsed = false;
if (errorParam) {
traverse(context, node.body, path => {
const n = path.node;
if (n.type === 'Identifier' && n.name === errorParam) {
hasUsed = true;
return traverse.STOP;
}
});
}
if (!hasUsed) {
context.report({
node,
messageId: 'use-error',
data: { paramName: errorParam },
});
}
},
};
},
};

View File

@@ -0,0 +1,8 @@
require('ts-node').register({
transpileOnly: true,
cwd: __dirname,
options: {},
})
const { flowPreset } = require('./index.ts')
module.exports = flowPreset

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Linter } from '@typescript-eslint/utils/ts-eslint';
import { noGetStateInComp } from './rules/no-get-state-in-comp';
import { noStateMutation } from './rules/no-state-mutation';
import { preferCurryCreate } from './rules/prefer-curry-create';
import { preferSelector } from './rules/prefer-selector';
import { preferShallow } from './rules/prefer-shallow';
import { properStoreTyping } from './rules/proper-store-typing';
import { storeFilenameConvention } from './rules/store-filename-convention';
import { storeNameConvention } from './rules/store-name-convention';
import { devtoolsConfig } from './rules/zustand-devtools-config';
import { preferMiddlewares } from './rules/zustand-prefer-middlewares';
export const flowPreset: Linter.Plugin = {
rules: {
'prefer-selector': preferSelector,
'prefer-shallow': preferShallow,
'store-name-convention': storeNameConvention,
'no-state-mutation': noStateMutation,
'store-filename-convention': storeFilenameConvention,
'prefer-curry-create': preferCurryCreate,
'prefer-middlewares': preferMiddlewares,
'devtools-config': devtoolsConfig,
'proper-store-typing': properStoreTyping,
'no-get-state-in-comp': noGetStateInComp,
},
configs: {
recommended: {
rules: {
'@coze-arch/zustand/no-get-state-in-comp': 'warn',
'@coze-arch/zustand/proper-store-typing': 'warn',
'@coze-arch/zustand/devtools-config': 'warn',
'@coze-arch/zustand/prefer-middlewares': 'warn',
'@coze-arch/zustand/prefer-curry-create': 'warn',
'@coze-arch/zustand/no-state-mutation': 'error',
'@coze-arch/zustand/store-filename-convention': 'warn',
'@coze-arch/zustand/store-name-convention': 'error',
'@coze-arch/zustand/prefer-selector': 'warn',
'@coze-arch/zustand/prefer-shallow': 'warn',
},
},
},
};

View File

@@ -0,0 +1,196 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect } from 'vitest';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import {
isObjLiteral,
extractIdentifiersFromPattern,
isSameIdentifier,
} from '../utils';
describe('isObjLiteral', () => {
it('should return true for ObjectExpression', () => {
const node = { type: 'ObjectExpression' } as unknown as TSESTree.Expression;
expect(isObjLiteral(node)).toBe(true);
});
it('should return true for ArrayExpression', () => {
const node = { type: 'ArrayExpression' } as unknown as TSESTree.Expression;
expect(isObjLiteral(node)).toBe(true);
});
it('should return false for other types', () => {
const node = { type: 'Literal' } as unknown as TSESTree.Expression;
expect(isObjLiteral(node)).toBe(false);
});
it('should return false for null or undefined', () => {
expect(isObjLiteral(null as any)).toBe(false);
expect(isObjLiteral(undefined as any)).toBe(false);
});
});
describe('extractIdentifiersFromPattern', () => {
it('should extract identifiers from a simple identifier pattern', () => {
const node = {
type: AST_NODE_TYPES.Identifier,
name: 'a',
};
const result = extractIdentifiersFromPattern(node as TSESTree.Identifier);
expect(result).toEqual([node]);
});
it('should extract identifiers from an object pattern', () => {
const node = {
type: 'ObjectPattern',
properties: [
{
type: 'Property',
value: { type: AST_NODE_TYPES.Identifier, name: 'a' },
},
{
type: 'RestElement',
argument: { type: AST_NODE_TYPES.Identifier, name: 'b' },
},
],
};
const result = extractIdentifiersFromPattern(
node as TSESTree.ObjectPattern,
);
expect(result).toEqual([
{ type: AST_NODE_TYPES.Identifier, name: 'a' },
{ type: AST_NODE_TYPES.Identifier, name: 'b' },
]);
});
it('should extract identifiers from an array pattern', () => {
const node = {
type: AST_NODE_TYPES.ArrayPattern,
elements: [
{ type: AST_NODE_TYPES.Identifier, name: 'a' },
null,
{ type: AST_NODE_TYPES.Identifier, name: 'b' },
],
};
const result = extractIdentifiersFromPattern(node as TSESTree.ArrayPattern);
expect(result).toEqual([
{ type: AST_NODE_TYPES.Identifier, name: 'a' },
{ type: AST_NODE_TYPES.Identifier, name: 'b' },
]);
});
it('should extract identifiers from nested patterns', () => {
const node = {
type: 'ObjectPattern',
properties: [
{
type: 'Property',
value: {
type: 'ArrayPattern',
elements: [
{ type: AST_NODE_TYPES.Identifier, name: 'a' },
{ type: AST_NODE_TYPES.Identifier, name: 'b' },
],
},
},
],
};
const result = extractIdentifiersFromPattern(
node as TSESTree.ObjectPattern,
);
expect(result).toEqual([
{ type: AST_NODE_TYPES.Identifier, name: 'a' },
{ type: AST_NODE_TYPES.Identifier, name: 'b' },
]);
});
it('should handle empty patterns', () => {
const node = {
type: AST_NODE_TYPES.ObjectPattern,
properties: [],
};
const result = extractIdentifiersFromPattern(
node as unknown as TSESTree.ObjectPattern,
);
expect(result).toEqual([]);
});
});
describe('isSameIdentifier', () => {
it('should return true for identical identifiers', () => {
//@ts-expect-error -- ignore mock
const id1: TSESTree.Identifier = {
name: 'foo',
range: [0, 5],
type: AST_NODE_TYPES.Identifier,
};
//@ts-expect-error -- ignore mock
const id2: TSESTree.Identifier = {
name: 'foo',
range: [0, 5],
type: AST_NODE_TYPES.Identifier,
};
expect(isSameIdentifier(id1, id2)).toBe(true);
});
it('should return false for different names', () => {
//@ts-expect-error -- ignore mock
const id1: TSESTree.Identifier = {
name: 'foo',
range: [0, 5],
type: AST_NODE_TYPES.Identifier,
};
//@ts-expect-error -- ignore mock
const id2: TSESTree.Identifier = {
name: 'bar',
range: [0, 5],
type: AST_NODE_TYPES.Identifier,
};
expect(isSameIdentifier(id1, id2)).toBe(false);
});
it('should return false for different ranges', () => {
//@ts-expect-error -- ignore mock
const id1: TSESTree.Identifier = {
name: 'foo',
range: [0, 5],
type: AST_NODE_TYPES.Identifier,
};
//@ts-expect-error -- ignore mock
const id2: TSESTree.Identifier = {
name: 'foo',
range: [0, 6],
type: AST_NODE_TYPES.Identifier,
};
expect(isSameIdentifier(id1, id2)).toBe(false);
});
it('should return false if one identifier is undefined', () => {
//@ts-expect-error -- ignore mock
const id1: TSESTree.Identifier = {
name: 'foo',
range: [0, 5],
type: AST_NODE_TYPES.Identifier,
};
expect(isSameIdentifier(id1, undefined)).toBe(false);
expect(isSameIdentifier(undefined, id1)).toBe(false);
});
it('should return false if both identifiers are undefined', () => {
expect(isSameIdentifier(undefined, undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ruleTester } from '../tester';
import { noGetStateInComp } from './index';
ruleTester.run('no-get-state-in-comp', noGetStateInComp, {
valid: [
{
code: 'function App() { const s = useStore() ;return (<div></div>)}',
filename: 'index.tsx',
},
{
code: 'const App = () => { const s = useStore() ;return (<div></div>)}',
filename: 'index.tsx',
},
],
invalid: [
{
code: 'const App = () => { const s = useStore.getState() ;return (<div></div>)}',
filename: 'index.tsx',
errors: [{ messageId: 'noGetState' }],
},
],
});

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { createRule, getZustandSetting, isNameMatchPattern } from '../utils';
export const noGetStateInComp = createRule({
name: 'zustand/no-get-state-in-comp',
defaultOptions: [],
meta: {
schema: [],
type: 'suggestion',
docs: {
description: 'Disallow use getState() in components.',
},
messages: {
noGetState:
'Avoid using {{storeName}}.getState() in react components. Use hooks instead.',
},
},
create: context => {
const { storeNamePattern } = getZustandSetting(context.settings);
return {
'BlockStatement > VariableDeclaration > VariableDeclarator > CallExpression > MemberExpression[property.name="getState"]'(
node: TSESTree.MemberExpression,
) {
if (node.object.type === AST_NODE_TYPES.Identifier) {
if (isNameMatchPattern(node.object.name, storeNamePattern)) {
const blockStatement = node.parent.parent?.parent
?.parent as TSESTree.BlockStatement;
const last = blockStatement.body[blockStatement.body.length - 1];
if (
last.type === AST_NODE_TYPES.ReturnStatement &&
last.argument?.type === AST_NODE_TYPES.JSXElement
) {
context.report({
node,
messageId: 'noGetState',
data: {
storeName: node.object.name,
},
});
}
}
}
},
};
},
});

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ruleTester } from '../tester';
import { noStateMutation } from './index';
ruleTester.run('no-state-mutation', noStateMutation, {
valid: [],
invalid: [
{
code: 'const state = useFooStore.getState(); state.foo += 1',
errors: [
{
messageId: 'noStateMutation',
},
],
},
{
code: 'const state = useFooStore.getState(); state.foo.bar.baz = 1',
errors: [
{
messageId: 'noStateMutation',
},
],
},
{
code: 'const { foo: { foo: [{ foo: bar }] }} = useFooStore.getState();bar.bar = 1',
errors: [
{
messageId: 'noStateMutation',
},
],
},
{
code: 'useFooStore.getState().foo.bar.baz = 1',
errors: [
{
messageId: 'noStateMutation',
},
],
},
{
code: 'const state = useFooStore.getState(); const state2 = useBarStore.getState(); const fn = () => () => state.foo.bar.baz = 1',
errors: [
{
messageId: 'noStateMutation',
},
],
},
],
});

View File

@@ -0,0 +1,109 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { findVariable } from '@typescript-eslint/utils/ast-utils';
import { TSESTree } from '@typescript-eslint/utils';
import {
createRule,
extractIdentifiersFromPattern,
isSameIdentifier,
isNameMatchPattern,
getZustandSetting,
} from '../utils';
const GET_STATE = 'getState';
export const noStateMutation = createRule({
name: 'zustand/no-state-mutation',
defaultOptions: [],
meta: {
schema: [],
type: 'problem',
docs: {
description: 'Disallow mutate store state directly',
},
messages: {
noStateMutation:
'Do not mutate the store state directly. Instead, use the set or setState API',
},
},
create(context) {
const stateIds: TSESTree.Identifier[] = [];
const idsToDetect: TSESTree.Identifier[] = [];
let assignNode: TSESTree.AssignmentExpression | undefined;
const { storeNamePattern } = getZustandSetting(context.settings);
return {
AssignmentExpression(node) {
assignNode = node;
if (node.left.type === 'MemberExpression') {
let n = node.left;
while (n.object && n.object.type === 'MemberExpression') {
n = n.object;
}
if (
n.object.type === 'CallExpression' &&
n.object.callee.type === 'MemberExpression' &&
n.object.callee.object.type === 'Identifier' &&
isNameMatchPattern(n.object.callee.object.name, storeNamePattern)
) {
context.report({
node,
messageId: 'noStateMutation',
});
return;
}
if (n.object.type === 'Identifier') {
idsToDetect.push(n.object);
}
}
},
VariableDeclarator(node) {
if (
node.init &&
node.init.type === 'CallExpression' &&
node.init.callee.type === 'MemberExpression' &&
node.init.callee.object.type === 'Identifier' &&
isNameMatchPattern(node.init.callee.object.name, storeNamePattern) &&
node.init.callee.property.type === 'Identifier' &&
node.init.callee.property.name === GET_STATE
) {
const identifiers = extractIdentifiersFromPattern(node.id);
stateIds.push(...identifiers);
}
},
'Program:exit'() {
if (assignNode) {
idsToDetect.forEach(modifyObjId => {
const variable = findVariable(
context.sourceCode.getScope(modifyObjId) as any,
modifyObjId.name,
);
if (
stateIds.find(i => isSameIdentifier(variable?.identifiers[0], i))
) {
context.report({
node: assignNode as TSESTree.AssignmentExpression,
messageId: 'noStateMutation',
});
}
});
}
},
};
},
});

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ruleTester } from '../tester';
import { preferCurryCreate } from '.';
const code = "import { create } from 'zustand';";
ruleTester.run('store-name-convention', preferCurryCreate, {
valid: [
{
code: `${code}const store = create()()`,
},
{
code: `${code} interface A {};const store = create<A>()()`,
},
{
code: 'const create = () => {}; const store = create()',
},
],
invalid: [
{
code: `${code}const store = create()`,
errors: [
{
messageId: 'preferCurryCreate',
suggestions: [
{
messageId: 'curryCreate',
output: `${code}const store = create()()`,
},
],
},
],
},
{
code: `${code}const store = create<T>()`,
errors: [
{
messageId: 'preferCurryCreate',
suggestions: [
{
messageId: 'curryCreate',
output: `${code}const store = create<T>()()`,
},
],
},
],
},
],
});

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { findVariable } from '@typescript-eslint/utils/ast-utils';
import { accessImportedIds, createRule, isSameIdentifier } from '../utils';
const STORE_CREATE_NAME = 'create';
export const preferCurryCreate = createRule({
name: 'zustand/prefer-curry-create',
defaultOptions: [],
meta: {
type: 'suggestion',
docs: {
description: 'Prefer using curry to create store pattern',
},
messages: {
preferCurryCreate:
'Do not use create(). Prefer using curry create pattern',
curryCreate: 'Use curry create pattern',
},
schema: [],
hasSuggestions: true,
},
create: accessImportedIds({
[STORE_CREATE_NAME]: ['zustand'],
})((context, _, ids) => {
return {
'VariableDeclarator > CallExpression'(node: TSESTree.CallExpression) {
if (
node.callee.type === AST_NODE_TYPES.Identifier &&
node.callee.name === STORE_CREATE_NAME
) {
const variable = findVariable(
context.sourceCode.getScope(node),
STORE_CREATE_NAME,
);
if (
isSameIdentifier(
variable?.identifiers[0],
ids.get(STORE_CREATE_NAME) as TSESTree.Identifier,
)
) {
context.report({
node: node.callee,
messageId: 'preferCurryCreate',
suggest: [
{
fix(fixer) {
return fixer.insertTextAfter(
node.typeArguments || node.callee,
'()',
);
},
messageId: 'curryCreate',
},
],
});
}
}
},
};
}),
});

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ruleTester } from '../tester';
import { preferSelector } from './index';
ruleTester.run('prefer-selector', preferSelector, {
valid: [
'foo()',
'new Foo()',
'useFooStore((s) => {})',
'useFooStore(selector)',
'useFooStore.getState()',
],
invalid: [
{
code: 'useFooStore()',
errors: [{ messageId: 'preferSelector' }],
},
{
code: 'const {a, b} = useFooStore()',
errors: [
{
messageId: 'preferSelector',
suggestions: [
{
messageId: 'useSelectorKeyValue',
output:
'const {a, b} = useFooStore((state) => ({a: state.a, b: state.b}))',
},
{
messageId: 'useSelectorUnderlineAlias',
output:
'const {a, b} = useFooStore(({a: _a, b: _b}) => ({a: _a, b: _b}))',
},
{
messageId: 'useSelectorDestruct',
output: 'const {a, b} = useFooStore(({a, b}) => ({a, b}))',
},
],
},
],
},
{
code: 'const {a:c, b} = useFooStore()',
errors: [
{
messageId: 'preferSelector',
suggestions: [
{
messageId: 'useSelectorKeyValue',
output:
'const {a:c, b} = useFooStore((state) => ({a: state.a, b: state.b}))',
},
{
messageId: 'useSelectorUnderlineAlias',
output:
'const {a:c, b} = useFooStore(({a: _a, b: _b}) => ({a: _a, b: _b}))',
},
{
messageId: 'useSelectorDestruct',
output: 'const {a:c, b} = useFooStore(({a, b}) => ({a, b}))',
},
],
},
],
},
{
code: 'const {a, ...b} = useFooStore()',
errors: [{ messageId: 'preferSelector' }],
},
],
});

View File

@@ -0,0 +1,146 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TSESTree } from '@typescript-eslint/utils';
import { RuleFixer } from '@typescript-eslint/utils/ts-eslint';
import { createRule, getZustandSetting, isNameMatchPattern } from '../utils';
const createFixer =
({
shouldFix,
node,
name,
}: {
shouldFix: boolean;
node: TSESTree.CallExpression;
name: string;
}) =>
({ paramsStr, retStr = paramsStr }: { paramsStr: string; retStr?: string }) =>
(fixer: RuleFixer) => {
if (!shouldFix) {
return null;
}
return fixer.replaceText(node, `${name}((${paramsStr}) => (${retStr}))`);
};
export const preferSelector = createRule({
name: 'zustand/preferSelector',
defaultOptions: [],
meta: {
schema: [],
type: 'suggestion',
docs: {
description: 'Prefer using selector function when calling {{ name }}',
},
messages: {
preferSelector: 'Prefer using selector function when calling {{ name }}',
useSelectorDestruct: 'Use selector function (object destruct style)',
useSelectorKeyValue: 'Use selector function (object property style)',
useSelectorUnderlineAlias:
'Use selector function (underLine alias style)',
},
hasSuggestions: true,
},
create(context) {
const { storeNamePattern } = getZustandSetting(context.settings);
return {
CallExpression(node) {
if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier'
) {
const { name } = node.callee;
if (isNameMatchPattern(name, storeNamePattern)) {
if (node.arguments.length === 0) {
const { parent } = node;
if (
parent.type === 'VariableDeclarator' &&
parent.id.type === 'ObjectPattern'
) {
let names: string[] = [];
for (const p of parent.id.properties) {
if (p.type === 'Property') {
if (p.key.type === 'Identifier' && p.key.name) {
names.push(p.key.name);
}
}
if (p.type === 'RestElement') {
names = [];
break;
}
}
const fixer = createFixer({
shouldFix: !!names?.length,
name,
node,
});
context.report({
node,
messageId: 'preferSelector',
data: {
name,
},
suggest: [
{
messageId: 'useSelectorKeyValue',
fix: fixer({
paramsStr: 'state',
retStr: `{${names
.filter(Boolean)
.map(it => `${it}: state.${it}`)
.join(', ')}}`,
}),
},
{
messageId: 'useSelectorUnderlineAlias',
fix: fixer({
paramsStr: `{${names
.map(it => `${it}: _${it}`)
.join(', ')}}`,
}),
},
{
messageId: 'useSelectorDestruct',
fix: fixer({
paramsStr: `{${names
.filter(Boolean)
.map(it => `${it}`)
.join(', ')}}`,
}),
},
],
});
return;
}
context.report({
node,
messageId: 'preferSelector',
data: {
name,
},
});
}
}
}
},
};
},
});

View File

@@ -0,0 +1,148 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ruleTester } from '../tester';
import { preferShallow } from './index';
const importSnippet = "\nimport { useShallow } from 'zustand/react/shallow';\n";
ruleTester.run('prefer-shallow', preferShallow, {
valid: [
'foo()',
'new Foo()',
'useShallowedFooStore()',
'useFooStore((s) => s.value)',
'useFooStore(selector)', // 暂时豁免
'useShallowFooStore(() => ({}))',
'useFooStore(useShallow(() => ({})))',
'useFooStore(useShallow(() => ([])))',
'useFooStore.getState()',
],
invalid: [
{
code: 'useFooStore(() => { return ({}) })',
errors: [
{
suggestions: [
{
output: `${importSnippet}useFooStore(useShallow(() => { return ({}) }))`,
messageId: 'useShallow',
},
],
messageId: 'preferShallow',
},
],
},
{
code: 'useFooStore(() => { return {} })',
errors: [
{
suggestions: [
{
output: `${importSnippet}useFooStore(useShallow(() => { return {} }))`,
messageId: 'useShallow',
},
],
messageId: 'preferShallow',
},
],
},
{
code: 'useFooStore(() => ({}))',
errors: [
{
suggestions: [
{
output: `${importSnippet}useFooStore(useShallow(() => ({})))`,
messageId: 'useShallow',
},
],
messageId: 'preferShallow',
},
],
},
{
code: 'useFooStore(() => { return ([]) })',
errors: [
{
suggestions: [
{
output: `${importSnippet}useFooStore(useShallow(() => { return ([]) }))`,
messageId: 'useShallow',
},
],
messageId: 'preferShallow',
},
],
},
{
code: 'useFooStore(() => { return [] })',
errors: [
{
suggestions: [
{
messageId: 'useShallow',
output: `${importSnippet}useFooStore(useShallow(() => { return [] }))`,
},
],
messageId: 'preferShallow',
},
],
},
{
code: 'useFooStore(() => ([]))',
errors: [
{
suggestions: [
{
messageId: 'useShallow',
output: `${importSnippet}useFooStore(useShallow(() => ([])))`,
},
],
messageId: 'preferShallow',
},
],
},
{
code: 'useFooStore(() => { const a = {}; return a;})',
errors: [
{
suggestions: [
{
output: `${importSnippet}useFooStore(useShallow(() => { const a = {}; return a;}))`,
messageId: 'useShallow',
},
],
messageId: 'preferShallow',
},
],
},
{
code: 'useFooStore(() => { const a = []; return a;})',
errors: [
{
suggestions: [
{
output: `${importSnippet}useFooStore(useShallow(() => { const a = []; return a;}))`,
messageId: 'useShallow',
},
],
messageId: 'preferShallow',
},
],
},
],
});

View File

@@ -0,0 +1,138 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ASTUtils, AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import {
createRule,
getZustandSetting,
isNameMatchPattern,
isObjLiteral,
} from '../utils';
export const preferShallow = createRule({
defaultOptions: [],
name: 'zustand/preferShallow',
meta: {
schema: [],
type: 'suggestion',
docs: {
description:
'Prefer using `useShallow` when store selector return an object literal to reduce re-render',
},
messages: {
preferShallow:
'Prefer using `useShallow` when calling {{name}} selector return an object literal to reduce re-render',
useShallow: 'Use `useShallow`',
},
hasSuggestions: true,
},
create(context) {
const problemNodes: TSESTree.CallExpression[] = [];
let insetRange = [0, 0] as [number, number];
const { storeNamePattern, shallowStoreNamePattern } = getZustandSetting(
context.settings,
);
return {
ImportDeclaration(node) {
if (node.range) {
insetRange = node.range;
}
},
CallExpression(node) {
if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier'
) {
const { name } = node.callee;
if (
isNameMatchPattern(name, storeNamePattern) &&
!isNameMatchPattern(name, shallowStoreNamePattern)
) {
if (node.arguments.length === 1) {
const expr = node.arguments[0];
if (
expr.type === 'FunctionExpression' ||
expr.type === 'ArrowFunctionExpression'
) {
if (expr.body.type === 'BlockStatement') {
const ret = expr.body.body.find(
i => i.type === 'ReturnStatement',
) as TSESTree.ReturnStatement | undefined;
if (ret?.argument) {
if (isObjLiteral(ret.argument)) {
problemNodes.push(node);
return;
}
if (ret.argument.type === 'Identifier') {
const variable = ASTUtils.findVariable(
context.sourceCode.getScope(ret),
ret.argument.name,
);
const n = variable?.defs[0].node;
if (n?.type === AST_NODE_TYPES.VariableDeclarator) {
if (isObjLiteral(n.init)) {
problemNodes.push(node);
return;
}
}
}
}
} else if (isObjLiteral(expr.body)) {
problemNodes.push(node);
return;
}
}
}
// deprecated use
}
}
},
'Program:exit'() {
problemNodes.forEach(node => {
const { name } = node.callee as any;
const expr = node.arguments[0];
context.report({
node,
messageId: 'preferShallow',
data: { name },
suggest: [
{
messageId: 'useShallow',
fix: fixer => {
return [
fixer.insertTextBefore(expr, 'useShallow('),
fixer.insertTextAfter(expr, ')'),
fixer.insertTextAfterRange(
insetRange,
"\nimport { useShallow } from 'zustand/react/shallow';\n",
),
];
},
},
],
});
});
},
};
},
});

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ruleTester } from '../tester';
import { properStoreTyping } from '.';
const code = "import { create } from 'zustand';";
ruleTester.run('proper-store-typing', properStoreTyping, {
valid: [
{
code: 'const foo = create()',
},
{
code: `${code}const store = create<T>()`,
},
{
code: `${code}const store = create<T>()()`,
},
],
invalid: [
{
code: `${code}const store = create()`,
errors: [
{
messageId: 'storeTyping',
},
],
},
{
code: `${code}const store = create()()`,
errors: [
{
messageId: 'storeTyping',
},
],
},
],
});

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { findVariable } from '@typescript-eslint/utils/ast-utils';
import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/utils';
import { accessImportedIds, isSameIdentifier, createRule } from '../utils';
const STORE_CREATE_NAME = 'create';
export const properStoreTyping = createRule({
name: 'zustand/proper-store-typing',
defaultOptions: [],
meta: {
type: 'suggestion',
docs: {
description: 'Disallow creating a store without a type parameter',
},
messages: {
storeTyping: 'Require a type parameter when creating a store',
},
schema: [],
hasSuggestions: true,
},
create: accessImportedIds({
[STORE_CREATE_NAME]: ['zustand'],
})((context, _, ids) => {
return {
CallExpression(node: TSESTree.CallExpression) {
if (
node.callee.type === AST_NODE_TYPES.Identifier &&
node.callee.name === STORE_CREATE_NAME
) {
const variable = findVariable(
context.sourceCode.getScope(node),
STORE_CREATE_NAME,
);
// zustand create
if (
isSameIdentifier(
variable?.identifiers[0],
ids.get(STORE_CREATE_NAME),
)
) {
if (!node.typeArguments) {
context.report({
node: node.callee,
messageId: 'storeTyping',
});
}
}
}
},
};
}),
});

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ruleTester } from '../tester';
import { storeFilenameConvention } from './index';
const code = "import { create } from 'zustand'; const store = create()() ";
ruleTester.run('store-filename-convention', storeFilenameConvention, {
valid: [
{
code,
filename: 'foo-store.ts',
},
{
code,
filename: 'fooStore.ts',
},
],
invalid: [
{
code,
filename: 'store.ts',
errors: [{ messageId: 'nameConvention' }],
},
{
code,
filename: 'foo.ts',
errors: [{ messageId: 'nameConvention' }],
},
],
});

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import path from 'path';
import { TSESTree } from '@typescript-eslint/utils';
import { findVariable } from '@typescript-eslint/utils/ast-utils';
import { accessImportedIds, createRule, isSameIdentifier } from '../utils';
const STORE_CREATE_NAME = 'create';
export interface OptionType {
pattern: string;
}
export const storeFilenameConvention = createRule<
OptionType[],
'nameConvention'
>({
defaultOptions: [{ pattern: '^([a-zA-Z0-9]+-?)+[sS]tore$' }],
name: 'zustand/store-filename-convention',
meta: {
type: 'suggestion',
docs: {
description: 'The store filename must match naming convention',
},
messages: {
nameConvention:
'The filename of the file that creates a store must match pattern {{pattern}}',
},
schema: [],
},
create: accessImportedIds<OptionType>({ [STORE_CREATE_NAME]: ['zustand'] })(
(context, options, ids) => {
return {
CallExpression(node: TSESTree.CallExpression) {
if (
node.callee.type === 'Identifier' &&
node.callee.name === STORE_CREATE_NAME
) {
const variable = findVariable(
context.sourceCode.getScope(node),
STORE_CREATE_NAME,
);
if (
isSameIdentifier(
ids.get(STORE_CREATE_NAME),
variable?.identifiers[0],
)
) {
const fileNamePattern = new RegExp(options[0].pattern);
const filename = path.basename(
context.filename,
path.extname(context.filename),
);
if (!fileNamePattern.test(filename)) {
context.report({
loc: { line: 0, column: 0 },
messageId: 'nameConvention',
data: {
pattern: fileNamePattern,
},
});
}
}
}
},
};
},
),
});

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ruleTester } from '../tester';
import { storeNameConvention } from './index';
ruleTester.run('store-name-convention', storeNameConvention, {
valid: [
{
code: "import { create } from 'zustand'; \n const useStore = create()",
},
{
code: "import { create } from 'zustand'; \n const useFooStore = create()",
},
{
code: "import { create } from 'zustand'; \n const createStore = () => { const useFooStore = create() }",
},
{
code: "import { create } from 'foo'; \n const foo = create() ",
},
],
invalid: [
{
code: "import { create } from 'zustand';\n const foo = create()",
errors: [{ messageId: 'nameConvention' }],
},
{
code: "import { create } from 'zustand';\n createStore = () => {const foo = create()}",
errors: [{ messageId: 'nameConvention' }],
},
],
});

View File

@@ -0,0 +1,84 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TSESTree } from '@typescript-eslint/utils';
import { findVariable } from '@typescript-eslint/utils/ast-utils';
import {
accessImportedIds,
createRule,
isSameIdentifier,
isNameMatchPattern,
getZustandSetting,
} from '../utils';
const STORE_CREATE_NAME = 'create';
export const storeNameConvention = createRule({
name: 'zustand/name-convention',
defaultOptions: [],
meta: {
schema: [],
type: 'suggestion',
docs: {
description: 'The store name must match the naming convention',
},
messages: {
nameConvention: 'The store name must match pattern {{pattern}}',
},
},
create: accessImportedIds({ [STORE_CREATE_NAME]: ['zustand'] })(
(context, _, ids) => {
const { storeNamePattern } = getZustandSetting(context.settings);
return {
'VariableDeclarator > CallExpression'(node: TSESTree.CallExpression) {
if (
node.callee.type === 'Identifier' &&
node.callee.name === STORE_CREATE_NAME
) {
const variable = findVariable(
context.sourceCode.getScope(node),
STORE_CREATE_NAME,
);
if (
isSameIdentifier(
ids.get(STORE_CREATE_NAME),
variable?.identifiers[0],
)
) {
const { parent } = node;
if (
parent.type === 'VariableDeclarator' &&
parent.id.type === 'Identifier'
) {
if (!isNameMatchPattern(parent.id.name, storeNamePattern)) {
context.report({
node: parent,
messageId: 'nameConvention',
data: {
pattern: storeNamePattern,
},
});
}
}
}
}
},
};
},
),
});

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as vitest from 'vitest';
import { RuleTester } from '@typescript-eslint/rule-tester';
import parser from '@typescript-eslint/parser';
RuleTester.afterAll = vitest.afterAll;
RuleTester.it = vitest.it;
RuleTester.itOnly = vitest.it.only;
RuleTester.describe = vitest.describe;
export const ruleTester = new RuleTester({
languageOptions: {
parser,
},
});

View File

@@ -0,0 +1,148 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AST_NODE_TYPES,
ESLintUtils,
TSESTree,
} from '@typescript-eslint/utils';
import {
RuleContext,
RuleListener,
SharedConfigurationSettings,
} from '@typescript-eslint/utils/ts-eslint';
const DEFAULT_STORE_NAME_PATTERN = '^use[a-zA-Z0-9_]*Store(Shallow)?$';
const DEFAULT_SHALLOW_STORE_NAME_PATTERN =
'^use(Shallow[a-zA-Z0-9_]*Store|[a-zA-Z0-9_]*StoreShallow)$';
export const isObjLiteral = (node?: TSESTree.Expression | null) =>
!!node &&
(node.type === 'ObjectExpression' || node.type === 'ArrayExpression');
export const isNameMatchPattern = (name: string, namePattern: string) =>
new RegExp(namePattern).test(name);
export const extractIdentifiersFromPattern = (
node: TSESTree.DestructuringPattern,
) => {
const identifiers: TSESTree.Identifier[] = [];
const extractIdentifiers = (node: TSESTree.DestructuringPattern) => {
if (node.type === 'Identifier') {
identifiers.push(node);
} else if (node.type === AST_NODE_TYPES.ObjectPattern) {
node.properties.forEach(prop => {
if (prop.type === AST_NODE_TYPES.Property && prop.value) {
extractIdentifiers(prop.value as TSESTree.DestructuringPattern);
} else if (
prop.type === AST_NODE_TYPES.RestElement &&
prop.argument.type === AST_NODE_TYPES.Identifier
) {
extractIdentifiers(prop.argument);
}
});
} else if (node.type === AST_NODE_TYPES.ArrayPattern) {
node.elements.forEach(element => {
if (element) {
extractIdentifiers(element);
}
});
} else if (node.type === AST_NODE_TYPES.RestElement) {
extractIdentifiers(node.argument);
}
};
extractIdentifiers(node);
return identifiers;
};
export const isSameIdentifier = (
identifierA?: TSESTree.Identifier,
identifierB?: TSESTree.Identifier,
) =>
!!(
identifierA &&
identifierB &&
identifierA.name === identifierB.name &&
identifierA.range?.[0] === identifierB.range?.[0] &&
identifierA.range?.[1] === identifierB.range?.[1]
);
export const createRule = ESLintUtils.RuleCreator(
() => 'https://github.com/pmndrs/zustand',
);
export const findCalleeNames = (
node: TSESTree.CallExpressionArgument,
names: string[] = [],
) => {
if (node && node.type === AST_NODE_TYPES.CallExpression) {
if (node.callee.type === AST_NODE_TYPES.Identifier) {
names.push(node.callee.name);
}
findCalleeNames(node.arguments[0], names);
}
return names;
};
type TypeIdsMap = Map<string, TSESTree.Identifier>;
type ContextMap<T> = Readonly<RuleContext<string, readonly T[]>>;
type CreateType<T> = (
context: ContextMap<T>,
optionsWithDefault: readonly T[],
ids: TypeIdsMap,
) => RuleListener;
export const accessImportedIds =
<T>(ids: Record<string, string[]>) =>
(originCreate: CreateType<T>) => {
return ((context, options) => {
const idsMap: TypeIdsMap = new Map();
const listeners = originCreate(
context as ContextMap<T>,
options as T[],
idsMap,
);
return {
...listeners,
ImportDeclaration(node) {
node.specifiers.forEach(s => {
if (
s.type === AST_NODE_TYPES.ImportSpecifier &&
Object.hasOwnProperty.call(ids, s.local.name) &&
ids[s.local.name].includes(node.source.value) &&
s.imported.type === AST_NODE_TYPES.Identifier
) {
idsMap.set(s.local.name, s.imported);
}
});
listeners.ImportDeclaration?.(node);
},
};
}) as Parameters<typeof createRule>[0]['create'];
};
export const getZustandSetting = (settings: SharedConfigurationSettings) => {
const defaultSetting = {
storeNamePattern: DEFAULT_STORE_NAME_PATTERN,
shallowStoreNamePattern: DEFAULT_SHALLOW_STORE_NAME_PATTERN,
};
return {
...defaultSetting,
...(settings.zustand || {}),
};
};

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ruleTester } from '../tester';
import { devtoolsConfig } from '.';
const code = "import { devtools } from 'zustand/middleware';";
ruleTester.run('devtools-config', devtoolsConfig, {
valid: [
{
code: 'devtools()',
},
{
code: `${code}devtools(() => {}, {enabled:true,name:'name'})`,
},
],
invalid: [
{
code: `${code}devtools(() => {});`,
errors: [
{
messageId: 'noEmptyCfg',
suggestions: [
{
messageId: 'addCfg',
output: `${code}devtools(() => {},{name:'DEV_TOOLS_NAME_SPACE'});`,
},
],
},
],
},
{
code: `${code}devtools(() => {}, {});`,
errors: [
{
messageId: 'nameCfg',
},
{
messageId: 'enabledCfg',
},
],
},
],
});

View File

@@ -0,0 +1,106 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { findVariable } from '@typescript-eslint/utils/ast-utils';
import { accessImportedIds, createRule, isSameIdentifier } from '../utils';
const VAR_NAME = 'devtools';
export const devtoolsConfig = createRule({
name: 'zustand/devToolsConfig',
defaultOptions: [],
meta: {
type: 'suggestion',
docs: {
description: 'middleware devtools config',
},
messages: {
noEmptyCfg: 'Middleware devtools need a config parameter',
addCfg: 'Use config parameter.',
nameCfg: 'Configure name fields to separate the namespace',
enabledCfg:
'Configure enabled fields and ensure the value is false when in production',
},
schema: [],
hasSuggestions: true,
},
create: accessImportedIds({
[VAR_NAME]: ['zustand/middleware', 'zustand/middleware/devtools'],
})((context, _, ids) => {
return {
CallExpression(node: TSESTree.CallExpression) {
if (
node.callee.type === AST_NODE_TYPES.Identifier &&
node.callee.name === VAR_NAME
) {
const variable = findVariable(
context.sourceCode.getScope(node),
VAR_NAME,
);
if (
variable &&
isSameIdentifier(variable?.identifiers[0], ids.get(VAR_NAME))
) {
const args = node.arguments[1];
if (!args) {
context.report({
node,
messageId: 'noEmptyCfg',
suggest: [
{
messageId: 'addCfg',
fix: fixer => {
if (node.arguments[0]) {
return fixer.insertTextAfter(
node.arguments[0],
",{name:'DEV_TOOLS_NAME_SPACE'}",
);
}
return null;
},
},
],
});
} else if (args.type === AST_NODE_TYPES.ObjectExpression) {
const hasProperty = (key: string) =>
args.properties.find(
p =>
p.type === AST_NODE_TYPES.Property &&
p.key.type === AST_NODE_TYPES.Identifier &&
p.key.name === key,
);
if (!hasProperty('name')) {
context.report({
node: args,
messageId: 'nameCfg',
});
}
if (!hasProperty('enabled')) {
context.report({
node: args,
messageId: 'enabledCfg',
});
}
}
}
}
},
};
}),
});

View File

@@ -0,0 +1,147 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ruleTester } from '../tester';
import { preferMiddlewares } from '.';
const code = "import { create } from 'zustand';";
ruleTester.run('prefer-middlewares', preferMiddlewares, {
valid: [
{
code: `${code}const store = create(m1())`,
options: [{ middlewares: ['m1'] }],
},
{
code: `${code}const store = create()(m1())`,
options: [{ middlewares: ['m1'] }],
},
{
code: `${code}const store = create(m1(m2(() => ({}))))`,
options: [{ middlewares: ['m1', 'm2'] }],
},
{
code: `${code}const store = create()(m1(m2(() => ({}))))`,
options: [{ middlewares: ['m1', 'm2'] }],
},
],
invalid: [
{
code: `${code}const store = create()`,
errors: [
{
messageId: 'preferMiddlewares',
suggestions: [
{
messageId: 'applyMiddlewares',
output: `${code}import { devtools } from 'zustand/middleware';\nconst store = create(devtools())`,
},
],
},
],
},
{
code: `${code}const store = create()()`,
errors: [
{
messageId: 'preferMiddlewares',
suggestions: [
{
messageId: 'applyMiddlewares',
output: `${code}import { devtools } from 'zustand/middleware';\nconst store = create()(devtools())`,
},
],
},
],
},
{
code: `${code}const store = create()(m1())`,
errors: [
{
messageId: 'preferMiddlewares',
suggestions: [
{
messageId: 'applyMiddlewares',
output: `${code}import { devtools } from 'zustand/middleware';\nconst store = create()(devtools(m1()))`,
},
],
},
],
},
{
code: `${code}const store = create()(m1(() => {}))`,
options: [{ middlewares: ['m2'] }],
errors: [
{
messageId: 'preferMiddlewares',
suggestions: [
{
messageId: 'applyMiddlewares',
output: `${code}const store = create()(m2(m1(() => {})))`,
},
],
},
],
},
{
code: `${code}const store = create()(m1(() => {}))`,
options: [
{
middlewares: [
{ name: 'm2', suggestImport: 'import {m2} from "m2";' },
],
},
],
errors: [
{
messageId: 'preferMiddlewares',
suggestions: [
{
messageId: 'applyMiddlewares',
output: `${code}import {m2} from "m2";const store = create()(m2(m1(() => {})))`,
},
],
},
],
},
{
code: `${code}const store = create()(m1(() => {}))`,
options: [
{
middlewares: [
{ name: 'm2', suggestImport: 'import {m2} from "m2";' },
{ name: 'm3', suggestImport: 'import {m3} from "m3";' },
],
},
],
errors: [
{
messageId: 'preferMiddlewares',
suggestions: [
{
messageId: 'applyMiddlewares',
output: `${code}import {m2} from "m2";const store = create()(m2(m1(() => {})))`,
},
{
messageId: 'applyMiddlewares',
output: `${code}import {m3} from "m3";const store = create()(m3(m1(() => {})))`,
},
],
},
],
},
],
});

View File

@@ -0,0 +1,156 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { findVariable } from '@typescript-eslint/utils/ast-utils';
import { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
import {
accessImportedIds,
createRule,
findCalleeNames,
isSameIdentifier,
} from '../utils';
const STORE_CREATE_NAME = 'create';
type Middleware =
| string
| { name: string; msg?: string; suggestImport?: string };
export interface Option {
middlewares: Middleware[];
}
export const preferMiddlewares = createRule<
Option[],
'preferMiddlewares' | 'applyMiddlewares'
>({
name: 'zustand/prefer-middlewares',
defaultOptions: [
{
middlewares: [
{
name: 'devtools',
msg: 'Advise using devtools middleware for convenient debugging',
suggestImport: "import { devtools } from 'zustand/middleware';\n",
},
],
},
],
meta: {
type: 'suggestion',
docs: {
description: 'Create store prefer using some middlewares',
},
messages: {
preferMiddlewares: '{{ msgs }}',
applyMiddlewares: 'Use {{ name }} middleware',
},
schema: {
type: 'array',
},
hasSuggestions: true,
},
create: accessImportedIds<Option>({
[STORE_CREATE_NAME]: ['zustand'],
})((context, options, ids) => {
let insetRange = [0, 0] as [number, number];
return {
ImportDeclaration(node) {
if (node.range) {
insetRange = node.range;
}
},
CallExpression(node: TSESTree.CallExpression) {
if (
node.callee.type === AST_NODE_TYPES.Identifier &&
node.callee.name === STORE_CREATE_NAME
) {
const variable = findVariable(
context.sourceCode.getScope(node),
STORE_CREATE_NAME,
);
// zustand create
if (
isSameIdentifier(
variable?.identifiers[0],
ids.get(STORE_CREATE_NAME),
)
) {
const callExpr =
node.arguments[0] ||
(node.parent.type === AST_NODE_TYPES.CallExpression
? node.parent.arguments[0]
: undefined);
const names = findCalleeNames(callExpr);
const mids = options[0].middlewares
.map(it =>
typeof it === 'object'
? it
: { name: it, msg: `advise using ${it}` },
)
.filter(m => !names.includes(m.name));
if (mids.length) {
context.report({
node: node.callee,
messageId: 'preferMiddlewares',
data: {
msgs: mids.map(it => it.msg).join(';'),
},
suggest: mids.map(mid => {
const n =
node.parent.type === AST_NODE_TYPES.CallExpression
? node.parent
: node;
const insertRange: [number, number] | undefined = n.arguments
.length
? n.arguments[0].range
: [n.range[1] - 1, n.range[1] - 1];
return {
fix(fixer) {
return [
fixer.insertTextBeforeRange(
insertRange,
`${mid.name}(`,
),
fixer.insertTextAfterRange(insertRange, ')'),
insetRange && mid.suggestImport
? fixer.insertTextAfterRange(
insetRange,
mid.suggestImport,
)
: null,
].filter(Boolean);
},
messageId: 'applyMiddlewares',
data: {
name: mid.name,
},
};
}) as ReportSuggestionArray<'applyMiddlewares'>,
});
}
}
}
},
};
}),
});

View File

@@ -0,0 +1,20 @@
{
"extends": "@coze-arch/ts-config/tsconfig.node.json",
"compilerOptions": {
"noImplicitAny": false,
"sourceMap": false,
"strictNullChecks": true,
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"],
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo"
},
"include": ["./src"],
"exclude": ["./src/**/*.test.ts"],
"$schema": "https://json.schemastore.org/tsconfig",
"references": [
{
"path": "../../config/ts-config/tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
],
"exclude": ["**/*"]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@coze-arch/ts-config/tsconfig.node.json",
"$schema": "https://json.schemastore.org/tsconfig",
"include": ["vitest.config.mts", "**/*.test.ts"],
"exclude": ["./dist"],
"references": [
{
"path": "./tsconfig.build.json"
}
],
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./",
"noImplicitAny": false,
"sourceMap": false,
"strictNullChecks": true,
"types": ["vitest/globals", "node"]
}
}

View File

@@ -0,0 +1,20 @@
import { defineConfig, coverageConfigDefaults } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
testTimeout: 30 * 1000,
coverage: {
provider: 'v8',
all: true,
exclude: [
...coverageConfigDefaults.exclude,
'src/index.js',
'src/index.ts',
'src/zustand/index.js',
'src/zustand/index.ts',
],
reporter: ['cobertura', 'text', 'html', 'clover', 'json', 'json-summary'],
},
},
});

View File

@@ -0,0 +1,68 @@
# @coze-arch/idl-parser
idl parser
## Overview
This package is part of the Coze Studio monorepo and provides architecture functionality. It serves as a core component in the Coze ecosystem.
## Getting Started
### Installation
Add this package to your `package.json`:
```json
{
"dependencies": {
"@coze-arch/idl-parser": "workspace:*"
}
}
```
Then run:
```bash
rush update
```
### Usage
```typescript
import { /* exported functions/components */ } from '@coze-arch/idl-parser';
// Example usage
// TODO: Add specific usage examples
```
## Features
- Core functionality for Coze Studio
- TypeScript support
- Modern ES modules
## API Reference
### Exports
- `*`
For detailed API documentation, please refer to the TypeScript definitions.
## Development
This package is built with:
- TypeScript
- Modern JavaScript
- Vitest for testing
- ESLint for code quality
## Contributing
This package is part of the Coze Studio monorepo. Please follow the monorepo contribution guidelines.
## License
Apache-2.0

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function filterKeys(obj: Record<string, any>, keys: string[]) {
const newObj: Record<string, any> = {};
for (const key of keys) {
newObj[key] = obj[key];
}
return newObj;
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable */
import * as t from '../src/proto';
const content = `
syntax = 'proto3';
// c1
message Foo { // c2
// c3
int32 code = 1; // c4
// c5
string content = 2;
// c6
string message = 3; // c7
}
`;
const document = t.parse(content);
console.log(JSON.stringify(document, null, 2));

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable */
import * as t from '../src/thrift';
import * as path from 'path';
const idl = `
/*
*/
struct UserDeleteDataMap {
1: required UserDeleteData DeleteData
2: string k2 (go.tag = 'json:\\"-\\"')
}
/*
We
*/
enum AvatarMetaType {
UNKNOWN = 0, // 没有数据, 错误数据或者系统错误降级
RANDOM = 1, // 在修改 or 创建时,用户未指定 name 或者选中推荐的文字时,程序随机选择的头像
}
`;
const document = t.parse(idl);
var c = path.join('a/b.thrift', './c.thrift');
console.log(JSON.stringify(document, null, 2));

View File

@@ -0,0 +1,213 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable */
import * as t from '../src/unify/index';
import * as path from 'path';
// const root = 'test/idl';
// const idl = path.resolve(process.cwd(), root, 'dep/common.thrift');
// const document = t.parse(idl, {
// root,
// namespaceRefer: true,
// cache: false,
// });
// // document.body[4]
// console.log('#gg', document);
const indexThriftContent = `
namespace java com.unify_idx
include 'unify_dependent1.thrift'
typedef unify_dependent1.Foo TFoo
enum Gender {
// male
Male // male tail
// female
Female // female tail
// mix
Mix
}
// const map<Gender, string> genderMap = {
// Gender.Male: '男性',
// Gender.Female: '女性',
// }
union FuncRequest {
1: unify_dependent1.Foo r_key1
2: TFoo list (go.tag = "json:\\"-\\"")
}
`;
const dep1ThriftContent = `
namespace js unify_dep1
typedef Foo Foo1
struct Foo {
1: string f_key1
}
`;
// const fileContentMap = {
// 'unify_index.thrift': indexThriftContent,
// 'unify_dependent1.thrift': dep1ThriftContent,
// };
const indexProtoContent = `
syntax = "proto3";
import "unify.dependent1.proto";
package a.b.c;
message Request {
repeated string key1 = 1[(api.key) = 'f'];
a.b.Foo key3 = 3;
// message Sub {
// enum Num {
// ONE = 1;
// }
// // string k1 = 1;
// Num k2 = 2;
// }
// Sub key2 = 2;
}
`;
const dep1ProtoContent = `
syntax = "proto3";
package a.b;
message Foo {
string f_key1 = 1;
message SubF {}
SubF f_key2 = 2;
}
`;
const fileContentMap = {
'unify_index.proto': indexProtoContent,
'unify.dependent1.proto': dep1ProtoContent,
};
// const document = t.parse(
// 'unify_index.proto',
// {
// root: '.',
// // namespaceRefer: true,
// },
// fileContentMap
// );
// // document.body[4]
// console.log(document);
const baseContent = `
syntax = "proto3";
package a.b;
message Bar {
message BarSub {
enum NumBar {
ONE = 1;
}
}
}
`;
const extraContent = `
syntax = "proto3";
package a.b;
message Extra {}
`;
const indexContent = `
syntax = "proto3";
package a.b;
import 'base.proto';
import 'extra.proto';
message Foo {
// message FooSub {
// enum NumFoo {
// TWO = 2;
// }
// }
// Foo.FooSub.NumFoo k1 = 1;
// FooSub.NumFoo k2 = 2;
// FooSub k3 = 3;
// repeated FooSub k4 = 4;
// map<string, FooSub.NumFoo> k5 = 5;
// Bar.BarSub.NumBar k10 = 10;
Bar.BarSub k11 = 11;
// repeated Bar.BarSub.NumBar k12 = 12;
// map<string, Bar.BarSub> k13 = 13;
}
// message Bar {
// message BarSub {
// enum NumBar {
// ONE = 1;
// }
// }
// }
`;
const document = t.parse(
'index.proto',
{ cache: false },
{
'index.proto': indexContent,
'base.proto': baseContent,
'extra.proto': extraContent,
}
);
const statement = document.statements[0] as t.InterfaceWithFields;
console.log(statement);
// const baseContent = `
// syntax = "proto3";
// package a.b;
// message Common {
// }
// `;
// const indexContent = `
// syntax = "proto3";
// message Foo {
// google.protobuf.Any k1 = 1;
// }
// `;
// const document = t.parse(
// 'index.proto',
// { cache: false },
// {
// 'index.proto': indexContent,
// // 'base.proto': baseContent,
// }
// );
// const { functions } = document.statements[0] as t.ServiceDefinition;
// console.log(functions);

View File

@@ -0,0 +1,3 @@
syntax = "proto3";
message Base {}

View File

@@ -0,0 +1 @@
struct Base {}

View File

@@ -0,0 +1,3 @@
syntax = "proto3";
message Basee {}

View File

@@ -0,0 +1 @@
struct Basee {}

View File

@@ -0,0 +1,7 @@
syntax = "proto3";
package common;
import "base.proto";
import "basee.proto";
message Common {}

View File

@@ -0,0 +1,6 @@
namespace go common
include 'base.thrift'
include 'basee.thrift'
struct Common {}

View File

@@ -0,0 +1,4 @@
syntax = "proto3";
message Foo {
string k1 = 1;,
}

View File

@@ -0,0 +1,3 @@
struct Foo {
1: string k1,,
}

View File

@@ -0,0 +1,5 @@
syntax = "proto3";
service Foo {
option (api.uri_prefix) = "//example.com";
}

View File

@@ -0,0 +1,5 @@
service Foo {
} (
api.uri_prefix = 'https://example.com'
)

View File

@@ -0,0 +1,11 @@
syntax = "proto3";
import "unify_dependent2.proto";
import "./dep/common.proto";
package unify_dep1;
message Foo {
string f_key1 = 1;
common.Common f_key2 = 2;
}

View File

@@ -0,0 +1,12 @@
include 'unify_dependent2.thrift'
include './dep/common.thrift'
namespace js unify_dep1
typedef Foo Foo1
struct Foo {
1: string f_key1
2: common.Common f_key2
}

View File

@@ -0,0 +1,9 @@
syntax = "proto3";
import "./unify_dependent1.proto";
package unify_idx;
enum Number {
ONE = 1;
}

View File

@@ -0,0 +1,5 @@
include './unify_dependent1.thrift'
enum Number {
ONE = 1,
}

View File

@@ -0,0 +1,2 @@
include "./unify_base.thrift"
include "./unify_base1.thrift"

View File

@@ -0,0 +1,29 @@
syntax = "proto3";
import "./unify_dependent1.proto";
import "unify_dependent2.proto";
package unify_idx;
// c0
enum Gender {
// c1
MALE = 1; // c2
// c3
FEMAL = 2; // c4
}
/* cm1 */
message Request {
// cm2
// repeated string key1 = 1[(api.key) = 'f'];
// unify_dep1.Foo key2 = 2;
Number key3 = 3 [(api.position) = 'query'];
}
service Example {
option (api.uri_prefix) = "//example.com";
rpc Biz1(Request) returns (Number) {
option (api.uri) = '/api/biz1';
}
}

View File

@@ -0,0 +1,21 @@
namespace go unify_idx
include './unify_dependent1.thrift'
include 'unify_dependent2.thrift'
typedef unify_dependent1.Foo TFoo
union FuncRequest {
1: unify_dependent1.Foo r_key1
2: TFoo r_key2
}
struct FuncResponse {
1: unify_dependent2.Number key2
}
service Example {
FuncResponse Func(1: FuncRequest req)
} (
api.uri_prefix = 'https://example.com'
)

View File

@@ -0,0 +1,7 @@
syntax = "proto3";
import "base.proto";
message Foo {
Base key1 = 1;
}

View File

@@ -0,0 +1,5 @@
include 'base.thrift'
struct Foo {
1: base.Base key1
}

View File

@@ -0,0 +1,12 @@
syntax = "proto3";
message Common {}
service Example {
rpc Func1 (Common) returns (Common) {
option (google.api.http) = {
get: "/ezo/web/v1/user_camp_result"
body: "*"
};
}
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as t from '../src/proto';
describe('ferry-parser', () => {
describe('proto field', () => {
it('should convert message field extenstions', () => {
const idl = `
syntax = "proto3";
enum Numbers {
ONE = 1;
}
message Foo {
string k1 = 1 [(api.position) = "query"];
string k2 = 2 [(api.position) = 'body'];
string k3 = 3 [(api.position) = 'path'];
string k4 = 4 [(api.position) = 'header'];
string k5 = 5 [(api.position) = 'entire_body'];
string k6 = 6 [(api.position) = 'raw_body', (aapi.position) = 'raw_body'];
string k7 = 7 [(api.position) = 'status_code', (api.positionn) = 'raw_body'];
string k10 = 10 [(api.key) = 'key10'];
string k11 = 11 [(api.key) = 'k11'];
bytes k12 = 12 [(api.web_type) = 'File'];
int32 k21 = 21 [(api.query) = 'k21'];
int32 k22 = 22 [(api.body) = 'k22'];
int32 k23 = 23 [(api.path) = 'k23'];
int32 k24 = 24 [(api.header) = 'k24'];
int32 k25 = 25 [(api.entire_body) = 'key25'];
int32 k26 = 26 [(api.raw_body) = 'key_26'];
int32 k27 = 27 [(api.status_code) = 'key-27'];
int32 k31 = 31 [(api.query) = 'key31', (api.web_type) = 'number', (api.position) = ''];
int32 k32 = 32 [(api.position) = 'body', (api.key)='key32', (api.value_type) = 'any'];
int32 k33 = 33 [(api.method) = 'POST', (api.position) = 'QUERY'];
int32 k34 = 34 ;
Numbers k35 = 35 [(api.position) = 'path'];
}
`;
const expected = [
{ position: 'query' },
{ position: 'body' },
{ position: 'path' },
{ position: 'header' },
{ position: 'entire_body' },
{ position: 'raw_body' },
{ position: 'status_code' },
{ key: 'key10' },
{},
{ web_type: 'File' },
{ position: 'query' },
{ position: 'body' },
{ position: 'path' },
{ position: 'header' },
{ position: 'entire_body', key: 'key25' },
{ position: 'raw_body', key: 'key_26' },
{ position: 'status_code', key: 'key-27' },
{ position: 'query', key: 'key31', web_type: 'number' },
{ position: 'body', key: 'key32', value_type: 'any' },
{},
undefined,
{ position: 'path' },
];
const document = t.parse(idl);
const Foo = (document.root.nested || {}).Foo as t.MessageDefinition;
const extensionConfigs = Object.values(Foo.fields).map(
field => field.extensionConfig,
);
return expect(extensionConfigs).to.eql(expected);
});
it('should convert message field extenstions using old rules', () => {
const idl = `
syntax = "proto3";
message Foo {
int32 k1 = 1 [(api_req).query = 'k1'];
int32 k2 = 2 [(api_req).body = 'k2'];
int32 k3 = 3 [(api_req).path = 'k3'];
int32 k4 = 4 [(api_req).header = 'k4'];
int32 k6 = 5 [(api_req).raw_body = 'key5'];
int32 k5 = 6 [(api_resp).header = 'key6'];
int32 k7 = 7 [(api_resp).http_code = 'key7'];
string k8 = 8 [(api_resp).body = 'k8'];
}
`;
const expected = [
{ position: 'query' },
{ position: 'body' },
{ position: 'path' },
{ position: 'header' },
{ position: 'raw_body', key: 'key5' },
{ position: 'header', key: 'key6' },
{},
{ position: 'body' },
];
const document = t.parse(idl);
const Foo = (document.root.nested || {}).Foo as t.MessageDefinition;
const extensionConfigs = Object.values(Foo.fields).map(
field => field.extensionConfig,
);
return expect(extensionConfigs).to.eql(expected);
});
it('should throw an error when using invalid type for a path parameter', () => {
const idl = `
syntax = "proto3";
message Foo {
bool k1 = 1 [(api.position) = "path"];
}
`;
try {
t.parse(idl);
} catch (err) {
const { message } = err;
const expected =
"the type of path parameter 'k1' in 'Foo' should be string or integer";
return expect(message).to.equal(expected);
}
return expect(true).to.equal(false);
});
});
});

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as path from 'path';
import * as t from '../src/proto';
describe('ferry-parser', () => {
describe('proto index', () => {
it('should convert the file content', () => {
const idl = path.resolve(__dirname, 'idl/index.proto');
const expected = { uri_prefix: '//example.com' };
const document = t.parse(idl);
const Foo = (document.root.nested || {}).Foo as t.ServiceDefinition;
return expect(Foo.extensionConfig).to.eql(expected);
});
it('should throw an error due to invalid file path', () => {
const idl = path.resolve(__dirname, 'idl/indexx.proto');
try {
t.parse(idl);
} catch (err) {
const { message } = err;
return expect(message).to.includes('no such file:');
}
return expect(true).to.equal(false);
});
it('should throw an syntax error', () => {
const idl = `
syntax = "proto3";
message Foo {
string k1 = 1;,
}
`;
const expected = "illegal token ','(source:4:0)";
try {
t.parse(idl);
} catch (err) {
const { message } = err;
return expect(message).to.equal(expected);
}
return expect(true).to.equal(false);
});
it('should throw an syntax error in the file content', () => {
const idl = path.resolve(__dirname, 'idl/error.proto');
const expected = '__tests__/idl/error.proto:3:0)';
try {
t.parse(idl);
} catch (err) {
const { message } = err;
return expect(message).to.includes(expected);
}
return expect(true).to.equal(false);
});
});
});

View File

@@ -0,0 +1,108 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as t from '../src/proto';
describe('ferry-parser', () => {
describe('proto method', () => {
it('should convert method extenstions', () => {
const idl = `
syntax = 'proto3';
message BizRequest {}
message BizResponse {}
service Foo {
rpc Biz1(BizRequest) returns (BizResponse) {
option (api.uri) = '/api/biz1';
}
rpc Biz2(BizRequest) returns (BizResponse) {
option (api.method) = "POST";
option (api.uri) = "/api/biz2";
option (api.serializer) = "json";
option (api.group) = 'user';
}
rpc Biz3(BizRequest) returns (BizResponse) {
option (api.get) ='/api/biz3';
option (api.serializer) ='form';
}
rpc Biz4(BizRequest) returns (BizResponse) {
option (api.post) ='/api/biz4';
option (api.serializer) ='urlencoded';
}
rpc Biz5(BizRequest) returns (BizResponse) {
option (api.put) ='/api/biz5';
}
rpc Biz6(BizRequest) returns (BizResponse) {
option (api.delete) ='/api/biz6';
}
rpc Biz7(BizRequest) returns (BizResponse);
}
`;
const expected = [
{ uri: '/api/biz1' },
{
method: 'POST',
uri: '/api/biz2',
serializer: 'json',
group: 'user',
},
{ method: 'GET', uri: '/api/biz3', serializer: 'form' },
{ method: 'POST', uri: '/api/biz4', serializer: 'urlencoded' },
{ method: 'PUT', uri: '/api/biz5' },
{ method: 'DELETE', uri: '/api/biz6' },
undefined,
];
const document = t.parse(idl);
const Foo = (document.root.nested || {}).Foo as t.ServiceDefinition;
const extensionConfigs = Object.values(Foo.methods).map(
func => func.extensionConfig,
);
return expect(extensionConfigs).to.eql(expected);
});
it('should convert method extenstions using old rules', () => {
const idl = `
syntax = 'proto3';
message BizRequest {}
message BizResponse {}
service Foo {
rpc Biz1(BizRequest) returns (BizResponse) {
option (api_method).get = "/api/biz1";
option (api_method).serializer = "json";
}
rpc Biz2(BizRequest) returns (BizResponse) {
option (pb_idl.api_method).post = "/api/biz2";
option (pb_idl.api_method).serializer = "form";
}
}
`;
const expected = [
{ method: 'GET', uri: '/api/biz1', serializer: 'json' },
{ method: 'POST', uri: '/api/biz2', serializer: 'form' },
];
const document = t.parse(idl);
const Foo = (document.root.nested || {}).Foo as t.ServiceDefinition;
const extensionConfigs = Object.values(Foo.methods).map(
func => func.extensionConfig,
);
return expect(extensionConfigs).to.eql(expected);
});
});
});

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as t from '../src/proto';
describe('ferry-parser', () => {
describe('proto service', () => {
it('should convert service extenstions', () => {
const idl = `
syntax = "proto3";
service Foo {
option (api.uri_prefix) = "//example.com";
}
`;
const expected = { uri_prefix: '//example.com' };
const document = t.parse(idl);
const Foo = (document.root.nested || {}).Foo as t.ServiceDefinition;
return expect(Foo.extensionConfig).to.eql(expected);
});
it('should convert service extenstions with package', () => {
const idl = `
syntax = "proto3";
package example;
service Foo {
option (api.uri_prefix) = "//example.com";
}
`;
const expected = { uri_prefix: '//example.com' };
const document = t.parse(idl);
const Foo = ((document.root.nested || {}).example.nested || {})
.Foo as t.ServiceDefinition;
return expect(Foo.extensionConfig).to.eql(expected);
});
});
});

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as t from '../src/thrift';
describe('ferry-parser', () => {
describe('thrift enum', () => {
it('should convert enum member comments', () => {
const idl = `
enum Bar {
// c1
ONE = 1, // c2
/* c3 */
TWO = 2, /* c4 */
// c5
/* c6 */
THTEE = 3, // c7
/* c8
c9 */
FOUR = 4
// c10
FIVE = 5; /* c11 */
}
`;
const expected = [
['c1', 'c2'],
[['c3'], ['c4']],
['c5', ['c6'], 'c7'],
[['c8', ' c9']],
['c10', ['c11']],
];
const document = t.parse(idl);
const { members } = document.body[0] as t.EnumDefinition;
const comments = members.map(member =>
member.comments.map(comment => comment.value),
);
return expect(comments).to.eql(expected);
});
});
});

View File

@@ -0,0 +1,226 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as t from '../src/thrift';
describe('ferry-parser', () => {
describe('thrift field', () => {
it('should convert struct field extenstions', () => {
const idl = `
enum Numbers {
ONE = 1
}
struct Foo {
1: string k1 (api.position = "query")
2: string k2 (api.position = 'body', aapi.position = 'query')
3: string k3 (api.position = 'path', api.positionn = 'query')
4: string k4 (api.position = 'header')
5: string k5 (api.position = 'entire_body')
6: string k6 (api.position = 'raw_body')
7: string k7 (api.position = 'status_code')
10: string k10 (api.key = 'key10')
11: string k11 (api.key = 'k11')
12: binary k12 (api.web_type = 'File')
13: string k13 (api.value_type = 'any')
14: list<string> k14 (api.value_type = 'any')
21: i32 k21 (api.query = 'k21[]')
22: i32 k22 (api.body = 'k22')
23: i32 k23 (api.path = 'k23')
24: i32 k24 (api.header = 'k24')
25: i32 k25 (api.entire_body = 'key25')
26: i32 k26 (api.raw_body = 'key_26')
27: i32 k27 (api.status_code = 'key-27')
31: i32 k31 (api.query = 'key31', api.web_type = 'number', api.position = '')
32: i32 k32 (api.position = 'body', api.key='key32', api.value_type = 'any')
33: i64 k33 (api.body="kk33, omitempty")
34: Numbers k34 (api.position = 'path')
}
`;
const expected = [
{ position: 'query' },
{ position: 'body' },
{ position: 'path' },
{ position: 'header' },
{ position: 'entire_body' },
{ position: 'raw_body' },
{ position: 'status_code' },
{ key: 'key10' },
{},
{ web_type: 'File' },
{ value_type: 'any' },
{ value_type: 'any' },
{ position: 'query' },
{ position: 'body' },
{ position: 'path' },
{ position: 'header' },
{ position: 'entire_body', key: 'key25' },
{ position: 'raw_body', key: 'key_26' },
{ position: 'status_code', key: 'key-27' },
{ position: 'query', key: 'key31', web_type: 'number' },
{ position: 'body', key: 'key32', value_type: 'any' },
{ position: 'body', key: 'kk33', tag: 'omitempty' },
{ position: 'path' },
];
const document = t.parse(idl);
const { fields } = document.body[1] as t.InterfaceWithFields;
const extensionConfigs = fields.map(field => field.extensionConfig);
return expect(extensionConfigs).to.eql(expected);
});
it('should convert union field extenstions', () => {
const idl = `
union Foo {
1: string k1 (api.position = "query")
}
`;
const expected = [{ position: 'query' }];
const document = t.parse(idl, { reviseTailComment: false });
const { fields } = document.body[0] as t.InterfaceWithFields;
const extensionConfigs = fields.map(field => field.extensionConfig);
return expect(extensionConfigs).to.eql(expected);
});
it('should convert struct field extenstions using agw specification', () => {
const idl = `
struct Foo {
1: string k1 (agw.source = 'query')
2: string k2 (agw.source = 'body')
3: string k3 (agw.source = 'path')
4: string k4 (agw.source = 'header')
5: string k5 (agw.source = 'raw_body')
6: string k6 (agw.target = 'header')
7: string k7 (agw.target = 'body')
7: string k7 (agw.target = 'http_code')
10: string k10 (agw.key = 'key10')
}
`;
const expected = [
{ position: 'query' },
{ position: 'body' },
{ position: 'path' },
{ position: 'header' },
{ position: 'raw_body' },
{ position: 'header' },
{ position: 'body' },
{ position: 'status_code' },
{ key: 'key10' },
];
const document = t.parse(idl);
const { fields } = document.body[0] as t.InterfaceWithFields;
const extensionConfigs = fields.map(field => field.extensionConfig);
return expect(extensionConfigs).to.eql(expected);
});
it('should convert struct field extenstions using golang tag', () => {
const idl = `
struct Foo {
1: string k1 (go.tag = "json:\\"key1\\"")
2: string k2 (go.tag = 'json:"key2,omitempty"')
3: string k3 (go.tag = 'jsonn:"key2,omitempty"')
}
`;
const expected = [{ key: 'key1' }, { key: 'key2', tag: 'omitempty' }, {}];
const document = t.parse(idl);
const { fields } = document.body[0] as t.InterfaceWithFields;
const extensionConfigs = fields.map(field => field.extensionConfig);
return expect(extensionConfigs).to.eql(expected);
});
it('should throw an error when using invalid type for a path parameter', () => {
const idl = `
struct Foo {
1: bool k1 (api.position = 'path')
}
`;
try {
t.parse(idl);
} catch (err) {
const { message } = err;
const expected =
"the type of path parameter 'k1' in 'Foo' should be string or integer";
return expect(message).to.equal(expected);
}
return expect(true).to.equal(false);
});
it('should revise field comments', () => {
const idl = `
struct Foo {
// c1
1: string k1 // c2
/* c3 */
2: string k2 /* c4 */
// c5
/* c6 */
3: string k3 // c7
/* c8
c9 */
4: string k4
// c10
5: string k5; /* c11 */
}
`;
const expected = [
['c1', 'c2'],
[['c3'], ['c4']],
['c5', ['c6'], 'c7'],
[['c8', ' c9']],
['c10', ['c11']],
];
const document = t.parse(idl);
const { fields } = document.body[0] as t.InterfaceWithFields;
const comments = fields.map(field =>
field.comments.map(comment => comment.value),
);
return expect(comments).to.eql(expected);
});
it('should revise empty field comments', () => {
const idl = `
/*
*/
struct Foo {
/**/
1: string k1
/* */
2: string k2
/** */
3: string k3
}
`;
const expected = [[['']], [['']], [['']]];
const document = t.parse(idl);
const { fields } = document.body[0] as t.InterfaceWithFields;
const comments = fields.map(field =>
field.comments.map(comment => comment.value),
);
return expect(comments).to.eql(expected);
});
});
});

View File

@@ -0,0 +1,116 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as t from '../src/thrift';
describe('ferry-parser', () => {
describe('thrift function', () => {
it('should convert function extenstions', () => {
const idl = `
service Foo {
BizResponse Biz1(1: BizRequest req) (api.uri = '/api/biz1')
BizResponse Biz2(1: BizRequest req) (
api.uri = '/api/biz2',
api.serializer = 'json',
api.method = 'POST',
api.group="user"
)
BizResponse Biz3(1: BizRequest req) (api.get = '/api/biz3', api.serializer='form')
BizResponse Biz4(1: BizRequest req) (api.post = '/api/biz4', api.serializer='urlencoded')
BizResponse Biz5(1: BizRequest req) (api.put = '/api/biz5', api.method = 'post')
BizResponse Biz6(1: BizRequest req) (api.delete = '/api/biz6', api.serializer='wow')
BizResponse Biz7(1: BizRequest req)
}
`;
const expected = [
{ uri: '/api/biz1' },
{
uri: '/api/biz2',
serializer: 'json',
method: 'POST',
group: 'user',
},
{ method: 'GET', uri: '/api/biz3', serializer: 'form' },
{ method: 'POST', uri: '/api/biz4', serializer: 'urlencoded' },
{ method: 'PUT', uri: '/api/biz5' },
{ method: 'DELETE', uri: '/api/biz6' },
undefined,
];
const document = t.parse(idl);
const { functions } = document.body[0] as t.ServiceDefinition;
const extensionConfigs = functions.map(func => func.extensionConfig);
return expect(extensionConfigs).to.eql(expected);
});
it('should convert function extenstions using agw specification', () => {
const idl = `
service Foo {
BizResponse Biz1(1: BizRequest req) (agw.uri = '/api/biz1')
BizResponse Biz2(1: BizRequest req) (
agw.uri = '/api/biz2',
agw.method = 'POST',
)
}
`;
const expected = [
{ uri: '/api/biz1' },
{ uri: '/api/biz2', method: 'POST' },
];
const document = t.parse(idl, { reviseTailComment: false });
const { functions } = document.body[0] as t.ServiceDefinition;
const extensionConfigs = functions.map(func => func.extensionConfig);
return expect(extensionConfigs).to.eql(expected);
});
it('should revise function comments', () => {
const idl = `
service Foo {
// c1
BizResponse Biz1(1: BizRequest req) // c2
/* c3 */
BizResponse Biz2(1: BizRequest req) /* c4 */
// c5
/* c6 */
BizResponse Biz3(1: BizRequest req) // c7
/* c8
c9 */
BizResponse Biz4(1: BizRequest req)
// c10
BizResponse Biz5(1: BizRequest req); /* c11 */
}
`;
const expected = [
['c1', 'c2'],
[['c3'], ['c4']],
['c5', ['c6'], 'c7'],
[['c8', ' c9']],
['c10', ['c11']],
];
const document = t.parse(idl);
const { functions } = document.body[0] as t.ServiceDefinition;
const comments = functions.map(func =>
func.comments.map(comment => comment.value),
);
return expect(comments).to.eql(expected);
});
});
});

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as path from 'path';
import * as t from '../src/thrift';
describe('ferry-parser', () => {
describe('thrift index', () => {
it('should convert the file content', () => {
const idl = path.resolve(__dirname, 'idl/index.thrift');
const expected = { uri_prefix: 'https://example.com' };
const document = t.parse(idl);
const { extensionConfig } = document.body[0] as t.ServiceDefinition;
return expect(extensionConfig).to.eql(expected);
});
it('should throw an error due to invalid file path', () => {
const idl = path.resolve(__dirname, 'idl/indexx.thrift');
try {
t.parse(idl);
} catch (err) {
const { message } = err;
return expect(message).to.includes('no such file:');
}
return expect(true).to.equal(false);
});
it('should throw an syntax error', () => {
const idl = `
struct Foo {
1: string k1,,
}
`;
const expected = 'FieldType expected but found: CommaToken(source:3:';
try {
t.parse(idl);
} catch (err) {
const { message } = err;
return expect(message).to.include(expected);
}
return expect(true).to.equal(false);
});
it('should throw an syntax error in the file content', () => {
const idl = path.resolve(__dirname, 'idl/error.thrift');
const expected = '__tests__/idl/error.thrift:2:16)';
try {
t.parse(idl);
} catch (err) {
const { message } = err;
return expect(message).includes(expected);
}
return expect(true).equal(false);
});
});
});

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as t from '../src/thrift';
describe('ferry-parser', () => {
describe('thrift service', () => {
it('should convert service extenstions', () => {
const idl = `
service Foo {
} (api.uri_prefix = 'https://example.com')
`;
const expected = { uri_prefix: 'https://example.com' };
const document = t.parse(idl);
const { extensionConfig } = document.body[0] as t.ServiceDefinition;
return expect(extensionConfig).to.eql(expected);
});
});
});

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["."]
}

View File

@@ -0,0 +1,174 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as t from '../src';
import { filterKeys } from './common';
describe('unify-parser', () => {
describe('thrift enum', () => {
it('should convert enum', () => {
const content = `
enum Number {
ONE = 1,
TWO,
}
`;
const document = t.parse(
'index.thrift',
{ cache: false },
{ 'index.thrift': content },
);
const { members } = document.statements[0] as t.EnumDefinition;
return expect(members.length).to.eql(2);
});
it('should resolve enum name', () => {
const content = `
enum Number {
ONE = 1,
TWO,
}
`;
const document = t.parse(
'index.thrift',
{ cache: false },
{ 'index.thrift': content },
);
const { name } = document.statements[0] as t.EnumDefinition;
expect(filterKeys(name, ['value', 'namespaceValue'])).to.eql({
value: 'Number',
namespaceValue: 'root.Number',
});
});
it('should not resolve enum name', () => {
const content = `
enum Number {
ONE = 1,
TWO,
}
`;
const document = t.parse(
'index.thrift',
{ namespaceRefer: false, cache: false },
{ 'index.thrift': content },
);
const { name } = document.statements[0] as t.EnumDefinition;
expect(filterKeys(name, ['value', 'namespaceValue'])).to.eql({
value: 'Number',
namespaceValue: undefined,
});
});
it('should revise enum comments', () => {
const content = `
enum Number {
// c1
ONE = 1, // c2
/* c3 */
TWO,/* c4 */
// c5
/* c6 */
FOUR = 4; // c7
/* c8
c9 */
FIVE;
SIX,
}
`;
const expected = [
['c1', 'c2'],
[['c3'], ['c4']],
['c5', ['c6'], 'c7'],
[['c8', ' c9']],
[],
];
const document = t.parse(
'index.thrift',
{ cache: false },
{ 'index.thrift': content },
);
const { members } = document.statements[0] as t.EnumDefinition;
const comments = members.map(field =>
field.comments.map(comment => comment.value),
);
return expect(comments).to.eql(expected);
});
it('should revise enum comments without dot', () => {
const content = `
enum Number {
// c1
ONE = 1 // c2
/* c3 */
TWO/* c4 */
// c5
/* c6 */
FOUR = 4 // c7
/* c8
c9 */
FIVE
SIX
}
`;
const expected = [
['c1', 'c2'],
[['c3'], ['c4']],
['c5', ['c6'], 'c7'],
[['c8', ' c9']],
[],
];
const document = t.parse(
'index.thrift',
{ cache: false },
{ 'index.thrift': content },
);
const { members } = document.statements[0] as t.EnumDefinition;
const comments = members.map(field =>
field.comments.map(comment => comment.value),
);
return expect(comments).to.eql(expected);
});
});
describe('proto enum', () => {
it('should convert enum', () => {
const content = `
enum Number {
ONE = 1;
TWO = 2;
}
`;
const document = t.parse(
'index.proto',
{ cache: false },
{ 'index.proto': content },
);
const { members } = document.statements[0] as t.EnumDefinition;
return expect(members.length).to.eql(2);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,343 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as t from '../src';
import { filterKeys } from './common';
describe('unify-parser', () => {
describe('thrift function', () => {
it('should convert function extenstions', () => {
const content = `
service Foo {
BizResponse Biz1(1: BizRequest req) (api.uri = '/api/biz1')
BizResponse Biz2(1: BizRequest req) (
api.uri = '/api/biz2',
api.serializer = 'json',
api.method = 'POST',
api.group="user"
)
BizResponse Biz3(1: BizRequest req) (api.get = '/api/biz3', api.serializer='form')
BizResponse Biz4(1: BizRequest req) (api.post = '/api/biz4', api.serializer='urlencoded')
BizResponse Biz5(1: BizRequest req) (api.put = '/api/biz5', api.method = 'post', api.version='1')
BizResponse Biz6(1: BizRequest req) (api.delete = '/api/biz6', api.serializer='wow', api.custom = '{"priority":1}')
BizResponse Biz7(1: BizRequest req)
}
`;
const expected = [
{ uri: '/api/biz1' },
{
uri: '/api/biz2',
serializer: 'json',
method: 'POST',
group: 'user',
},
{ method: 'GET', uri: '/api/biz3', serializer: 'form' },
{ method: 'POST', uri: '/api/biz4', serializer: 'urlencoded' },
{ method: 'PUT', uri: '/api/biz5', version: '1' },
{ method: 'DELETE', uri: '/api/biz6', custom: '{"priority":1}' },
{},
];
const document = t.parse(
'index.thrift',
{ cache: false },
{ 'index.thrift': content },
);
const { functions } = document.statements[0] as t.ServiceDefinition;
const extensionConfigs = functions.map(func => func.extensionConfig);
return expect(extensionConfigs).to.eql(expected);
});
it('should convert function extenstions using agw specification', () => {
const content = `
service Foo {
BizResponse Biz1(1: BizRequest req) (agw.uri = '/api/biz1')
BizResponse Biz2(1: BizRequest req) (
agw.uri = '/api/biz2',
agw.method = 'POST',
)
}
`;
const expected = [
{ uri: '/api/biz1' },
{ uri: '/api/biz2', method: 'POST' },
];
const document = t.parse(
'index.thrift',
{ cache: false },
{ 'index.thrift': content },
);
const { functions } = document.statements[0] as t.ServiceDefinition;
const extensionConfigs = functions.map(func => func.extensionConfig);
return expect(extensionConfigs).to.eql(expected);
});
it('should revise function comments', () => {
const content = `
service Foo {
// c1
BizResponse Biz1(1: BizRequest req) // c2
/* c3 */
BizResponse Biz2(1: BizRequest req) /* c4 */
// c5
/* c6 */
BizResponse Biz3(1: BizRequest req) // c7
/* c8
c9 */
BizResponse Biz4(1: BizRequest req)
BizResponse Biz5(1: BizRequest req)
}
`;
const expected = [
['c1', 'c2'],
[['c3'], ['c4']],
['c5', ['c6'], 'c7'],
[['c8', ' c9']],
[],
];
const document = t.parse(
'index.thrift',
{ cache: false },
{ 'index.thrift': content },
);
const { functions } = document.statements[0] as t.ServiceDefinition;
const comments = functions.map(func =>
func.comments.map(comment => comment.value),
);
return expect(comments).to.eql(expected);
});
it('should resolve function name', () => {
const content = `
service Foo {
BizResponse Biz1(1: BizRequest req) // c2
}
`;
const document = t.parse(
'index.thrift',
{ cache: false },
{ 'index.thrift': content },
);
const { functions } = document.statements[0] as t.ServiceDefinition;
const { name } = functions[0];
return expect(filterKeys(name, ['value', 'namespaceValue'])).to.eql({
value: 'Biz1',
namespaceValue: 'root.Biz1',
});
});
it('should resolve func type', () => {
const baseContent = `
namespace go test_base
struct Response {}
`;
const funcContent = `
include "./base.thrift"
service Foo {
base.Response Biz1(1: BizRequest req) // c2
}
`;
const document = t.parse(
'index.thrift',
{ cache: false },
{
'base.thrift': baseContent,
'index.thrift': funcContent,
},
);
const { functions } = document.statements[0] as t.ServiceDefinition;
const identifier = functions[0].returnType as t.Identifier;
return expect(filterKeys(identifier, ['value', 'namespaceValue'])).to.eql(
{
value: 'base.Response',
namespaceValue: 'test_base.Response',
},
);
});
});
describe('proto method', () => {
it('should convert method extenstions', () => {
const content = `
syntax = 'proto3';
message BizRequest {}
message BizResponse {}
service Foo {
rpc Biz1(BizRequest) returns (BizResponse) {
option (api.uri) = '/api/biz1';
}
rpc Biz2(BizRequest) returns (BizResponse) {
option (api.method) = "POST";
option (api.uri) = "/api/biz2";
option (api.serializer) = "json";
option (api.group) = 'user';
}
rpc Biz3(BizRequest) returns (BizResponse) {
option (api.get) ='/api/biz3';
option (api.serializer) ='form';
}
rpc Biz4(BizRequest) returns (BizResponse) {
option (api.post) ='/api/biz4';
option (api.serializer) ='urlencoded';
}
rpc Biz5(BizRequest) returns (BizResponse) {
option (api.put) ='/api/biz5';
}
rpc Biz6(BizRequest) returns (BizResponse) {
option (api.delete) ='/api/biz6';
}
rpc Biz7(BizRequest) returns (BizResponse);
}
`;
const expected = [
{ uri: '/api/biz1' },
{
method: 'POST',
uri: '/api/biz2',
serializer: 'json',
group: 'user',
},
{ method: 'GET', uri: '/api/biz3', serializer: 'form' },
{ method: 'POST', uri: '/api/biz4', serializer: 'urlencoded' },
{ method: 'PUT', uri: '/api/biz5' },
{ method: 'DELETE', uri: '/api/biz6' },
{},
];
const document = t.parse(
'index.proto',
{ cache: false },
{ 'index.proto': content },
);
const { functions } = document.statements[2] as t.ServiceDefinition;
const extensionConfigs = functions.map(func => func.extensionConfig);
return expect(extensionConfigs).to.eql(expected);
});
it('should resolve method name', () => {
const content = `
syntax = 'proto3';
message BizRequest {}
message BizResponse {}
service Foo {
rpc Biz1(BizRequest) returns (BizResponse) {
option (api.uri) = '/api/biz1';
}
}
`;
const document = t.parse(
'index.proto',
{ cache: false },
{ 'index.proto': content },
);
const { functions } = document.statements[2] as t.ServiceDefinition;
const { name } = functions[0];
return expect(filterKeys(name, ['value', 'namespaceValue'])).to.eql({
value: 'Biz1',
namespaceValue: 'root.Foo.Biz1',
});
});
it('should resolve response type', () => {
const baseContent = `
syntax = 'proto3';
package test_base;
message Response {}
`;
const funcContent = `
import "base.proto";
syntax = 'proto3';
message BizRequest {}
service Foo {
rpc Biz1(BizRequest) returns (test_base.Response) {
option (api.uri) = '/api/biz1';
}
}
`;
const document = t.parse(
'index.proto',
{ cache: false },
{
'base.proto': baseContent,
'index.proto': funcContent,
},
);
const { functions } = document.statements[1] as t.ServiceDefinition;
const identifier = functions[0].returnType as t.Identifier;
return expect(filterKeys(identifier, ['value', 'namespaceValue'])).to.eql(
{
value: 'base.Response',
namespaceValue: 'test_base.Response',
},
);
});
it('should resolve response type within the same namespace', () => {
const baseContent = `
syntax = 'proto3';
package same;
message Response {}
message Request {}
`;
const funcContent = `
import "base.proto";
syntax = 'proto3';
package same;
service Foo {
rpc Biz1(Request) returns (same.Response) {
option (api.uri) = '/api/biz1';
}
}
`;
const document = t.parse(
'index.proto',
{ cache: false },
{
'base.proto': baseContent,
'index.proto': funcContent,
},
);
const { functions } = document.statements[0] as t.ServiceDefinition;
const returnType = functions[0].returnType as t.Identifier;
const requestType = functions[0].fields[0].fieldType as t.Identifier;
expect(filterKeys(requestType, ['value', 'namespaceValue'])).to.eql({
value: 'base.Request',
namespaceValue: 'same.Request',
});
expect(filterKeys(returnType, ['value', 'namespaceValue'])).to.eql({
value: 'base.Response',
namespaceValue: 'same.Response',
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More