snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './component-props-naming';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: 'export const MyComponent= (props: MyComponentProps) => <div>{props.message}</div>;',
filename: 'test.tsx',
},
{
code: 'export const MyComponent = ({ message }: MyComponentProps) => <div>{message}</div>;',
filename: 'test.tsx',
},
],
invalid: [
{
code: 'export const MyComponent = (props: OwnProps) => <div>{props.message}</div>;',
errors: [
{
messageId: 'invalidPropsTypeName',
},
],
output:
'export const MyComponent = (props: MyComponentProps) => <div>{props.message}</div>;',
filename: 'test.tsx',
},
{
code: 'export const MyComponent = ({ message }: OwnProps) => <div>{message}</div>;',
errors: [
{
messageId: 'invalidPropsTypeName',
},
],
output:
'export const MyComponent = ({ message }: MyComponentProps) => <div>{message}</div>;',
filename: 'test.tsx',
},
],
});

View File

@@ -0,0 +1,71 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'component-props-naming';
const checkPropsTypeName = ({
node,
context,
functionName,
}: {
node: any;
context: any;
functionName: string;
}) => {
const expectedPropTypeName = `${functionName}Props`;
if (!functionName.match(/^[A-Z]/)) return;
node.params.forEach((param: any) => {
if (
(param.type === 'ObjectPattern' || param?.type === 'Identifier') &&
param.typeAnnotation?.typeAnnotation.type === 'TSTypeReference' &&
param.typeAnnotation?.typeAnnotation.typeName?.type === 'Identifier'
) {
const { typeName } = param.typeAnnotation.typeAnnotation;
const actualPropTypeName = typeName.name;
if (actualPropTypeName !== expectedPropTypeName) {
context.report({
node: param,
messageId: 'invalidPropsTypeName',
data: { expectedPropTypeName, actualPropTypeName },
fix: (fixer: any) =>
fixer.replaceText(typeName, expectedPropTypeName),
});
}
}
});
};
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description: 'Ensure component props follow naming convention',
},
fixable: 'code',
schema: [],
messages: {
invalidPropsTypeName:
"Expected prop type to be '{{ expectedPropTypeName }}' but found '{{ actualPropTypeName }}'",
},
},
create: (context) => {
return {
ArrowFunctionExpression: (node: any) => {
if (
node.parent?.type === 'VariableDeclarator' &&
node.parent.id?.type === 'Identifier'
) {
checkPropsTypeName({
node,
context,
functionName: node.parent.id.name,
});
}
},
FunctionDeclaration: (node: any) => {
checkPropsTypeName({ node, context, functionName: node.id.name });
},
};
},
});

View File

@@ -0,0 +1,69 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './effect-components';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: `const TestComponentEffect = () => <></>;`,
filename: 'test.tsx',
},
{
code: `const TestComponent = () => <div></div>;`,
filename: 'test.tsx',
},
{
code: `export const useUpdateEffect = () => null;`,
},
{
code: `export const useUpdateEffect = () => <></>;`,
filename: 'test.tsx',
},
{
code: `const TestComponent = () => <><div></div></>;`,
filename: 'test.tsx',
},
{
code: `const TestComponentEffect = () => null;`,
},
{
code: `const TestComponentEffect = () => {
useEffect(() => {}, []);
return null;
}`,
},
{
code: `const TestComponentEffect = () => {
useEffect(() => {}, []);
return <></>;
}`,
filename: 'test.tsx',
},
],
invalid: [
{
code: 'const TestComponent = () => <></>;',
output: 'const TestComponentEffect = () => <></>;',
errors: [
{
messageId: 'addEffectSuffix',
},
],
filename: 'test.tsx',
},
{
code: 'const TestComponentEffect = () => <><div></div></>;',
output: 'const TestComponent = () => <><div></div></>;',
errors: [
{
messageId: 'removeEffectSuffix',
},
],
filename: 'test.tsx',
},
],
});

View File

@@ -0,0 +1,100 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'effect-components';
const isPascalCase = (input: string) => !!input.match(/^[A-Z][a-zA-Z0-9_]*/);
const isReturningEmptyFragmentOrNull = (node: any) =>
(node.body.type === 'JSXFragment' && node.body.children.length === 0) ||
(node.body.type === 'Literal' && node.body.value === null) ||
(node.body.type === 'BlockStatement' &&
node.body.body.some(
(statement: any) =>
statement.type === 'ReturnStatement' &&
((statement.argument?.type === 'JSXFragment' &&
statement.argument.children.length === 0) ||
(statement.argument?.type === 'JSXElement' &&
statement.argument.openingElement.name.type ===
'JSXIdentifier' &&
statement.argument.openingElement.name.name ===
'React.Fragment' &&
statement.argument.children.length === 0) ||
(statement.argument?.type === 'Literal' &&
statement.argument.value === null)),
));
const checkEffectComponent = ({
context,
identifier,
node,
}: {
context: any;
identifier: any;
node: any;
}) => {
const componentName = identifier.name;
if (!isPascalCase(componentName)) return;
const isEffectComponent = isReturningEmptyFragmentOrNull(node);
const hasEffectSuffix = componentName.endsWith('Effect');
if (isEffectComponent && !hasEffectSuffix) {
context.report({
node,
messageId: 'addEffectSuffix',
data: { componentName },
fix: (fixer: any) =>
fixer.replaceText(identifier, componentName + 'Effect'),
});
return;
}
if (hasEffectSuffix && !isEffectComponent) {
context.report({
node,
messageId: 'removeEffectSuffix',
data: { componentName },
fix: (fixer: any) =>
fixer.replaceText(identifier, componentName.replace('Effect', '')),
});
}
};
export const rule = defineRule({
meta: {
docs: {
description:
'Effect components should end with the Effect suffix. This rule checks only components that are in PascalCase and that return a JSX fragment or null. Any renderProps or camelCase components are ignored.',
},
messages: {
addEffectSuffix:
'Effect component {{ componentName }} should end with the Effect suffix.',
removeEffectSuffix:
"Component {{ componentName }} shouldn't end with the Effect suffix because it doesn't return a JSX fragment or null.",
},
type: 'suggestion',
schema: [],
fixable: 'code',
},
create: (context) => {
const checkFunctionExpressionEffectComponent = (node: any) =>
node.parent?.type === 'VariableDeclarator' &&
node.parent.id?.type === 'Identifier'
? checkEffectComponent({
context,
identifier: node.parent.id,
node,
})
: undefined;
return {
ArrowFunctionExpression: checkFunctionExpressionEffectComponent,
FunctionDeclaration: (node: any) =>
checkEffectComponent({ context, identifier: node.id, node }),
FunctionExpression: checkFunctionExpressionEffectComponent,
};
},
});

View File

@@ -0,0 +1,81 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './enforce-module-boundaries';
const depConstraints = [
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
];
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: "import { isDefined } from 'twenty-shared';",
options: [{ depConstraints }],
filename: '/project/packages/twenty-front/src/utils.ts',
},
{
code: "import { Button } from 'twenty-ui';",
options: [{ depConstraints }],
filename: '/project/packages/twenty-front/src/components.tsx',
},
{
code: "import { isDefined } from 'twenty-shared';",
options: [{ depConstraints }],
filename: '/project/packages/twenty-server/src/utils.ts',
},
{
code: "import { helper } from './local';",
options: [{ depConstraints }],
filename: '/project/packages/twenty-front/src/utils.ts',
},
{
code: "import lodash from 'lodash';",
options: [{ depConstraints }],
filename: '/project/packages/twenty-front/src/utils.ts',
},
{
code: "import { isDefined } from 'twenty-shared';",
options: [{ depConstraints: [] }],
filename: '/project/packages/twenty-front/src/utils.ts',
},
],
invalid: [
{
code: "import { ServerService } from 'twenty-server';",
options: [{ depConstraints }],
filename: '/project/packages/twenty-front/src/bad-import.ts',
errors: [{ messageId: 'moduleBoundaryViolation' }],
},
{
code: "import { Component } from 'twenty-front';",
options: [{ depConstraints }],
filename: '/project/packages/twenty-server/src/bad-import.ts',
errors: [{ messageId: 'moduleBoundaryViolation' }],
},
{
code: "import { Component } from 'twenty-front';",
options: [{ depConstraints }],
filename: '/project/packages/twenty-shared/src/bad-import.ts',
errors: [{ messageId: 'moduleBoundaryViolation' }],
},
{
code: "import { ServerThing } from 'twenty-server';",
options: [{ depConstraints }],
filename: '/project/packages/twenty-shared/src/bad-import.ts',
errors: [{ messageId: 'moduleBoundaryViolation' }],
},
],
});

View File

@@ -0,0 +1,169 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'enforce-module-boundaries';
const PACKAGE_TAG_MAP: Record<string, string[]> = {
'twenty-front': ['scope:frontend'],
'twenty-server': ['scope:backend'],
'twenty-ui': ['scope:shared'],
'twenty-shared': ['scope:shared'],
'twenty-emails': ['scope:backend'],
'twenty-sdk': ['scope:sdk', 'scope:shared'],
'twenty-zapier': ['scope:zapier'],
'twenty-oxlint-rules': ['scope:shared'],
'twenty-docs': ['scope:docs'],
'twenty-e2e-testing': ['scope:testing'],
'create-twenty-app': ['scope:create-app'],
};
const WORKSPACE_PACKAGES = Object.keys(PACKAGE_TAG_MAP);
type DepConstraint = {
sourceTag: string;
onlyDependOnLibsWithTags: string[];
};
const extractPackageFromPath = (filePath: string): string | null => {
const match = filePath.match(/packages\/([^/]+)\//);
return match ? match[1] : null;
};
const resolveImportPackage = (importSource: string): string | null => {
for (const packageName of WORKSPACE_PACKAGES) {
if (
importSource === packageName ||
importSource.startsWith(`${packageName}/`)
) {
return packageName;
}
}
if (importSource.startsWith('@/')) {
return null;
}
return null;
};
const isImportAllowed = (
sourcePackage: string,
targetPackage: string,
depConstraints: DepConstraint[],
): boolean => {
if (sourcePackage === targetPackage) {
return true;
}
const sourceTags = PACKAGE_TAG_MAP[sourcePackage] ?? [];
const targetTags = PACKAGE_TAG_MAP[targetPackage] ?? [];
for (const constraint of depConstraints) {
if (!sourceTags.includes(constraint.sourceTag)) {
continue;
}
const allowed = targetTags.some((targetTag) =>
constraint.onlyDependOnLibsWithTags.includes(targetTag),
);
if (!allowed) {
return false;
}
}
return true;
};
const checkImport = (
node: any,
context: any,
depConstraints: DepConstraint[],
) => {
const importSource = node.source?.value;
if (!importSource || typeof importSource !== 'string') {
return;
}
const targetPackage = resolveImportPackage(importSource);
if (!targetPackage) {
return;
}
const sourcePackage = extractPackageFromPath(context.filename);
if (!sourcePackage) {
return;
}
if (!isImportAllowed(sourcePackage, targetPackage, depConstraints)) {
context.report({
node,
messageId: 'moduleBoundaryViolation',
data: {
sourcePackage,
targetPackage,
importSource,
},
});
}
};
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Enforce module boundaries between packages based on scope tags',
},
schema: [
{
type: 'object',
properties: {
depConstraints: {
type: 'array',
items: {
type: 'object',
properties: {
sourceTag: { type: 'string' },
onlyDependOnLibsWithTags: {
type: 'array',
items: { type: 'string' },
},
},
},
},
},
},
],
messages: {
moduleBoundaryViolation:
"Package '{{ sourcePackage }}' cannot import from '{{ targetPackage }}' (import '{{ importSource }}'). Check depConstraints configuration.",
},
},
create: (context) => {
const options = (context.options as [{ depConstraints: DepConstraint[] }])?.[0];
const depConstraints = options?.depConstraints ?? [];
if (depConstraints.length === 0) {
return {};
}
return {
ImportDeclaration: (node: any) => {
checkImport(node, context, depConstraints);
},
ImportExpression: (node: any) => {
if (node.source?.type === 'Literal') {
checkImport(
{ source: node.source },
context,
depConstraints,
);
}
},
};
},
});

View File

@@ -0,0 +1,361 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './folder-structure';
const ruleTester = new RuleTester();
const DUMMY_CODE = 'const x = 1;';
const PROJECT = '/project/packages/twenty-front';
const filename = (path: string) => `${PROJECT}/${path}`;
ruleTester.run(RULE_NAME, rule, {
valid: [
// Files outside src/modules/ are ignored
{
code: DUMMY_CODE,
filename: filename('src/utils/helper.ts'),
},
{
code: DUMMY_CODE,
filename: filename('src/components/Button.tsx'),
},
// types/ root folder allows anything
{
code: DUMMY_CODE,
filename: filename('src/modules/types/global.d.ts'),
},
{
code: DUMMY_CODE,
filename: filename('src/modules/types/nested/deep.ts'),
},
// Hooks: use{PascalCase}.(ts|tsx)
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/hooks/useCreateOneRecord.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/hooks/useMyHook.tsx',
),
},
// Hook tests: use{PascalCase}.(test|spec).(ts|tsx)
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/hooks/__tests__/useCreateOneRecord.test.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/hooks/__tests__/useMyHook.test.tsx',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/hooks/__tests__/useMyHook.spec.tsx',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/hooks/__tests__/__snapshots__/snap.ts',
),
},
// Hooks internal
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/hooks/internal/useInternalHook.ts',
),
},
// Hooks __mocks__
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/hooks/__mocks__/useMyHook.ts',
),
},
// Utils: {camelCase}.(ts|tsx)
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/utils/buildQuery.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/utils/getCardComponent.tsx',
),
},
// Util tests: {camelCase}.(test|spec).(ts|tsx)
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/utils/__tests__/buildQuery.test.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/utils/__tests__/buildQuery.spec.ts',
),
},
// Utils kebab-case subfolders (leaf directories)
{
code: DUMMY_CODE,
filename: filename(
'src/modules/workflow/utils/cron-to-human/parseCron.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/workflow/utils/cron-to-human/types/CronParts.ts',
),
},
// Standard allowed subdirs
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/components/RecordTable.tsx',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/states/someState.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/constants/DEFAULT_VALUE.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/graphql/queries.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/effect-components/MyEffect.tsx',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/validation-schemas/schema.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/contexts/MyContext.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/scopes/MyScope.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/services/recordService.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/errors/NotFoundError.ts',
),
},
// Nested modules up to depth 4
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/sub-module/components/Widget.tsx',
),
},
{
code: DUMMY_CODE,
filename: filename('src/modules/a/b/c/d/hooks/useDeep.ts'),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/a/b/c/d/e/hooks/useDeep.ts',
),
},
// Files directly in module folders
{
code: DUMMY_CODE,
filename: filename('src/modules/my-feature/index.ts'),
},
// __tests__ inside module
{
code: DUMMY_CODE,
filename: filename(
'src/modules/object-record/__tests__/integration.test.ts',
),
},
// Test helpers and non-standard files in __tests__/ are allowed (leaf context)
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/hooks/__tests__/TestWrapper.tsx',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/hooks/__tests__/constants/mockData.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/utils/__tests__/helpers/setup.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/utils/__tests__/kebab-name.test.js',
),
},
],
invalid: [
// Files directly in modules root
{
code: DUMMY_CODE,
filename: filename('src/modules/loose-file.ts'),
errors: [{ messageId: 'noFilesInModulesRoot' }],
},
// Non-kebab-case module names
{
code: DUMMY_CODE,
filename: filename(
'src/modules/MyModule/hooks/useHook.ts',
),
errors: [{ messageId: 'moduleNameNotKebabCase' }],
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/camelCase/hooks/useHook.ts',
),
errors: [{ messageId: 'moduleNameNotKebabCase' }],
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/graphWidgetChart/file.ts',
),
errors: [{ messageId: 'moduleNameNotKebabCase' }],
},
// Module nesting too deep (depth > 5)
{
code: DUMMY_CODE,
filename: filename(
'src/modules/a/b/c/d/e/f/hooks/useDeep.ts',
),
errors: [{ messageId: 'moduleTooDeep' }],
},
// Depth check fires before kebab-case check at max depth
{
code: DUMMY_CODE,
filename: filename(
'src/modules/a/b/c/d/e/camelCaseDir/file.ts',
),
errors: [{ messageId: 'moduleTooDeep' }],
},
// Bad hook file naming
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/hooks/badName.ts',
),
errors: [{ messageId: 'hookFileNaming' }],
},
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/hooks/helper.ts',
),
errors: [{ messageId: 'hookFileNaming' }],
},
// Invalid hooks folder entry
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/hooks/random-folder/file.ts',
),
errors: [{ messageId: 'invalidHooksEntry' }],
},
// Bad util file naming (PascalCase not allowed)
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/utils/BadName.ts',
),
errors: [{ messageId: 'utilFileNaming' }],
},
// Bad util file naming (kebab-case files not allowed)
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/utils/kebab-name.ts',
),
errors: [{ messageId: 'utilFileNaming' }],
},
// Non-kebab-case subfolder in utils
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/utils/camelCase/file.ts',
),
errors: [{ messageId: 'invalidUtilsEntry' }],
},
// hooks/internal/ too deep
{
code: DUMMY_CODE,
filename: filename(
'src/modules/my-feature/hooks/internal/internal/internal/useHook.ts',
),
errors: [{ messageId: 'hooksInternalTooDeep' }],
},
],
});

View File

@@ -0,0 +1,269 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'folder-structure';
const KEBAB_CASE_REGEX = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
const USE_PASCAL_CASE_FILE_REGEX = /^use[A-Z][a-zA-Z0-9]*\.(ts|tsx)$/;
const CAMEL_CASE_UTIL_FILE_REGEX = /^[a-z][a-zA-Z0-9]*\.(ts|tsx)$/;
const LEAF_SUBDIRS_WITHOUT_FILE_NAMING_CONSTRAINT = new Set([
'states',
'types',
'graphql',
'components',
'effect-components',
'constants',
'validation-schemas',
'contexts',
'scopes',
'services',
'errors',
]);
const TESTING_DIRS = new Set(['__tests__', '__mocks__', '__snapshots__']);
const MAX_MODULE_DEPTH = 5;
const MAX_HOOKS_INTERNAL_DEPTH = 2;
type PathContext =
| { type: 'modules_root' }
| { type: 'module'; depth: number }
| { type: 'hooks'; internalDepth: number }
| { type: 'utils' }
| { type: 'leaf' };
type ValidationError = {
messageId: string;
data: Record<string, string | number>;
};
const isFile = (segment: string, isLastSegment: boolean): boolean =>
isLastSegment;
const validateSegment = (
segment: string,
context: PathContext,
isLastSegment: boolean,
):
| { nextContext: PathContext; error?: undefined }
| { error: ValidationError } => {
switch (context.type) {
case 'modules_root': {
if (isFile(segment, isLastSegment)) {
return {
error: {
messageId: 'noFilesInModulesRoot',
data: { name: segment },
},
};
}
if (segment === 'types') {
return { nextContext: { type: 'leaf' } };
}
if (!KEBAB_CASE_REGEX.test(segment)) {
return {
error: {
messageId: 'moduleNameNotKebabCase',
data: { name: segment },
},
};
}
return { nextContext: { type: 'module', depth: 1 } };
}
case 'module': {
if (isFile(segment, isLastSegment)) {
return { nextContext: context };
}
if (TESTING_DIRS.has(segment)) {
return { nextContext: { type: 'leaf' } };
}
if (segment === 'hooks') {
return {
nextContext: { type: 'hooks', internalDepth: 0 },
};
}
if (segment === 'utils') {
return { nextContext: { type: 'utils' } };
}
if (LEAF_SUBDIRS_WITHOUT_FILE_NAMING_CONSTRAINT.has(segment)) {
return { nextContext: { type: 'leaf' } };
}
if (context.depth >= MAX_MODULE_DEPTH) {
return {
error: {
messageId: 'moduleTooDeep',
data: { max: MAX_MODULE_DEPTH, name: segment },
},
};
}
if (!KEBAB_CASE_REGEX.test(segment)) {
return {
error: {
messageId: 'moduleNameNotKebabCase',
data: { name: segment },
},
};
}
return {
nextContext: { type: 'module', depth: context.depth + 1 },
};
}
case 'hooks': {
if (isFile(segment, isLastSegment)) {
if (!USE_PASCAL_CASE_FILE_REGEX.test(segment)) {
return {
error: {
messageId: 'hookFileNaming',
data: { name: segment },
},
};
}
return { nextContext: context };
}
if (TESTING_DIRS.has(segment)) {
return { nextContext: { type: 'leaf' } };
}
if (segment === 'internal') {
if (context.internalDepth >= MAX_HOOKS_INTERNAL_DEPTH) {
return {
error: {
messageId: 'hooksInternalTooDeep',
data: { max: MAX_HOOKS_INTERNAL_DEPTH },
},
};
}
return {
nextContext: {
type: 'hooks',
internalDepth: context.internalDepth + 1,
},
};
}
return {
error: {
messageId: 'invalidHooksEntry',
data: { name: segment },
},
};
}
case 'utils': {
if (isFile(segment, isLastSegment)) {
if (!CAMEL_CASE_UTIL_FILE_REGEX.test(segment)) {
return {
error: {
messageId: 'utilFileNaming',
data: { name: segment },
},
};
}
return { nextContext: context };
}
if (TESTING_DIRS.has(segment)) {
return { nextContext: { type: 'leaf' } };
}
// Intentionally treated as leaf — files inside these subfolders
// skip camelCase enforcement to allow flexible organization
// (e.g. utils/cron-to-human/types/CronParts.ts is valid).
if (KEBAB_CASE_REGEX.test(segment)) {
return { nextContext: { type: 'leaf' } };
}
return {
error: {
messageId: 'invalidUtilsEntry',
data: { name: segment },
},
};
}
case 'leaf': {
return { nextContext: { type: 'leaf' } };
}
}
};
const extractModulesRelativePath = (
filename: string,
): string | null => {
const marker = '/src/modules/';
const index = filename.indexOf(marker);
if (index === -1) {
return null;
}
return filename.slice(index + marker.length);
};
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Enforce folder structure conventions inside src/modules/ (kebab-case module dirs, allowed subdirs, hook/util file naming)',
},
schema: [],
messages: {
noFilesInModulesRoot:
"Files are not allowed directly in src/modules/. Found '{{ name }}'.",
moduleNameNotKebabCase:
"Module folder '{{ name }}' must be kebab-case (e.g. 'my-module').",
moduleTooDeep:
"Module folder '{{ name }}' exceeds maximum nesting depth of {{ max }}.",
hookFileNaming:
"Hook file '{{ name }}' must match use{PascalCase}.(ts|tsx) (e.g. 'useMyHook.ts').",
hooksInternalTooDeep:
'hooks/internal/ nesting exceeds maximum depth of {{ max }}.',
invalidHooksEntry:
"Unexpected entry '{{ name }}' in hooks/. Only hook files, __tests__/, __mocks__/, and internal/ are allowed.",
utilFileNaming:
"Util file '{{ name }}' must match {camelCase}.(ts|tsx) (e.g. 'myUtil.ts').",
invalidUtilsEntry:
"Unexpected entry '{{ name }}' in utils/. Only util files, __tests__/, and kebab-case subfolders are allowed.",
},
},
create: (context) => {
return {
Program: (node: any) => {
const relativePath = extractModulesRelativePath(
context.filename,
);
if (!relativePath) {
return;
}
const segments = relativePath.split('/').filter(Boolean);
if (segments.length === 0) {
return;
}
let currentContext: PathContext = { type: 'modules_root' };
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const isLastSegment = i === segments.length - 1;
const result = validateSegment(
segment,
currentContext,
isLastSegment,
);
if ('error' in result) {
context.report({
node,
messageId: result.error.messageId,
data: result.error.data,
});
return;
}
currentContext = result.nextContext;
}
},
};
},
});

View File

@@ -0,0 +1,295 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './graphql-resolvers-should-be-guarded';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: `
class TestResolver {
@Query()
@UseGuards(UserAuthGuard, NoPermissionGuard)
testQuery() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestResolver {
@Query()
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
testQuery() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestResolver {
@Query()
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
testQuery() {}
}
`,
filename: 'test.tsx',
},
{
code: `
@UseGuards(UserAuthGuard, NoPermissionGuard)
class TestResolver {
@Query()
testQuery() {}
}
`,
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
class TestResolver {
@Query()
testQuery() {}
}
`,
filename: 'test.tsx',
},
{
code: `
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
class TestResolver {
@Query()
testQuery() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestResolver {
@Subscription()
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
testSubscription() {}
}
`,
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
class TestResolver {
@Subscription()
testSubscription() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestResolver {
regularMethod() {}
}
`,
},
{
code: `
class TestResolver {
@ResolveField()
testField() {}
}
`,
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.WORKSPACE))
class TestResolver {
@Mutation(() => String)
async createSomething() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestResolver {
@Mutation(() => String)
@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.WORKSPACE))
async createSomething() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestResolver {
@Mutation(() => String)
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
async createSomething() {}
}
`,
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
class TestResolver {
@Mutation(() => String)
async createSomething() {}
}
`,
filename: 'test.tsx',
},
],
invalid: [
{
code: `
class TestResolver {
@Query()
testQuery() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestResolver {
@Mutation()
testMutation() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestResolver {
@Subscription()
testSubscription() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestResolver {
@Query()
@UseGuards(UserAuthGuard)
testQuery() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestResolver {
@Query()
@UseGuards(CaptchaGuard)
testQuery() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
@UseGuards(CaptchaGuard)
class TestResolver {
@Query()
testQuery() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestResolver {
@Subscription()
@UseGuards(WorkspaceAuthGuard)
testSubscription() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard)
class TestResolver {
@Subscription()
testSubscription() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestResolver {
@Mutation(() => String)
@UseGuards(WorkspaceAuthGuard)
async createSomething() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard)
class TestResolver {
@Mutation(() => String)
async createSomething() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
filename: 'test.tsx',
},
],
});

View File

@@ -0,0 +1,12 @@
import { createGuardedEndpointRule } from '../utils/createGuardedEndpointRule';
export const RULE_NAME = 'graphql-resolvers-should-be-guarded';
export const rule = createGuardedEndpointRule({
triggerDecorators: ['Query', 'Mutation', 'Subscription'],
messageId: 'graphqlResolversShouldBeGuarded',
description:
'GraphQL root resolvers (Query, Mutation, Subscription) should have authentication guards (UserAuthGuard or WorkspaceAuthGuard) or be explicitly marked as public (PublicEndpointGuard) and permission guards (SettingsPermissionGuard or CustomPermissionGuard) to maintain our security model.',
message:
'All GraphQL resolvers must have authentication guards (@UseGuards(UserAuthGuard/WorkspaceAuthGuard)) and permission guards (@UseGuards(..., SettingsPermissionGuard(PermissionFlagType.XXX)), CustomPermissionGuard for custom logic, or NoPermissionGuard for special cases like onboarding).',
});

View File

@@ -0,0 +1,75 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './inject-workspace-repository';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: `
class MyWorkspaceService {
constructor(@InjectWorkspaceRepository() private repository) {}
}
`,
filename: 'my.workspace-service.ts',
},
{
code: `
class AnotherWorkspaceService {
constructor(private myWorkspaceService: MyWorkspaceService) {}
}
`,
filename: 'another.workspace-service.ts',
},
],
invalid: [
{
code: `
class MyService {
constructor(@InjectWorkspaceRepository() private repository) {}
}
`,
filename: 'my.workspace-service.ts',
errors: [{ messageId: 'invalidClassName' }],
},
{
code: `
class MyWorkspaceService {
constructor(@InjectWorkspaceRepository() private repository) {}
}
`,
filename: 'my.service.ts',
errors: [{ messageId: 'invalidFileName' }],
},
{
code: `
class MyService {
constructor(@InjectWorkspaceRepository() private repository) {}
}
`,
filename: 'my.service.ts',
errors: [
{ messageId: 'invalidClassName' },
{ messageId: 'invalidFileName' },
],
},
{
code: `
class AnotherWorkspaceService {
constructor(private myWorkspaceService: MyWorkspaceService) {}
}
`,
filename: 'another.service.ts',
errors: [{ messageId: 'invalidFileName' }],
},
{
code: `
class AnotherService {
constructor(private myWorkspaceService: MyWorkspaceService) {}
}
`,
filename: 'another.workspace-service.ts',
errors: [{ messageId: 'invalidClassName' }],
},
],
});

View File

@@ -0,0 +1,82 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'inject-workspace-repository';
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Ensure class names and file names follow the required pattern when using @InjectWorkspaceRepository.',
},
schema: [],
messages: {
invalidClassName: "Class name should end with 'WorkspaceService'.",
invalidFileName:
"File name should end with '.workspace-service.ts'.",
},
},
create: (context) => {
return {
MethodDefinition: (node: any) => {
const filename = context.filename;
if (
!filename.endsWith('.workspace-service.ts') &&
!filename.endsWith('.service.ts')
) {
return;
}
if (node.kind === 'constructor') {
const hasInjectWorkspaceRepositoryDecoratorOrWorkspaceService =
node.value.params.some((param: any) => {
if (param.type === 'TSParameterProperty') {
const hasDecorator = param.decorators?.some(
(decorator: any) => {
return (
decorator.expression.type === 'CallExpression' &&
(decorator.expression.callee as any).name ===
'InjectWorkspaceRepository'
);
},
);
const hasWorkspaceServiceType =
param.parameter.typeAnnotation?.typeAnnotation &&
param.parameter.typeAnnotation.typeAnnotation.type ===
'TSTypeReference' &&
(
param.parameter.typeAnnotation.typeAnnotation
.typeName as any
).name.endsWith('WorkspaceService');
return hasDecorator || hasWorkspaceServiceType;
}
return false;
});
if (
hasInjectWorkspaceRepositoryDecoratorOrWorkspaceService
) {
const className = (node.parent.parent as any).id?.name;
const filename = context.filename;
if (!className?.endsWith('WorkspaceService')) {
context.report({
node: node.parent,
messageId: 'invalidClassName',
});
}
if (!filename.endsWith('.workspace-service.ts')) {
context.report({
node: node.parent,
messageId: 'invalidFileName',
});
}
}
}
},
};
},
});

View File

@@ -0,0 +1,135 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './matching-state-variable';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: 'const variable = useAtomStateValue(variableState);',
},
{
code: 'const [variable, setVariable] = useAtomState(variableState);',
},
{
code: 'const variable = useAtomComponentStateValue(variableComponentState);',
},
{
code: 'const [variable, setVariable] = useAtomComponentState(variableComponentState);',
},
{
code: 'const variable = useAtomFamilyStateValue(variableFamilyState, key);',
},
{
code: 'const variable = useAtomComponentFamilyStateValue(variableComponentFamilyState, key);',
},
{
code: 'const [variable, setVariable] = useAtomComponentFamilyState(variableComponentFamilyState, key);',
},
{
code: "const variableViews = useAtomFamilyStateValue(variableFamilyState, 'views');",
},
{
code: "const variableViewFields = useAtomFamilyStateValue(variableFamilyState, 'viewFields');",
},
{
code: "const [variableViews, setVariableViews] = useAtomComponentFamilyState(variableComponentFamilyState, 'views');",
},
{
code: "const setVariableViews = useSetAtomFamilyState(variableFamilyState, 'views');",
},
{
code: 'const setVariable = useSetAtomState(variableState);',
},
{
code: 'const setVariable = useSetAtomComponentState(variableComponentState);',
},
{
code: 'const setVariable = useSetAtomFamilyState(variableFamilyState, key);',
},
{
code: 'const setVariable = useSetAtomComponentFamilyState(variableComponentFamilyState, key);',
},
],
invalid: [
{
code: 'const myValue = useAtomStateValue(variableState);',
output: 'const variable = useAtomStateValue(variableState);',
errors: [{ messageId: 'invalidVariableName' }],
},
{
code: 'const [myValue, setMyValue] = useAtomState(variableState);',
output: 'const [variable, setVariable] = useAtomState(variableState);',
errors: [
{ messageId: 'invalidVariableName' },
{ messageId: 'invalidSetterName' },
],
},
{
code: 'const [myValue] = useAtomState(variableState);',
output: 'const [variable] = useAtomState(variableState);',
errors: [{ messageId: 'invalidVariableName' }],
},
{
code: 'const [, setMyValue] = useAtomState(variableState);',
output: 'const [, setVariable] = useAtomState(variableState);',
errors: [{ messageId: 'invalidSetterName' }],
},
{
code: 'const myValue = useAtomComponentStateValue(variableComponentState);',
output: 'const variable = useAtomComponentStateValue(variableComponentState);',
errors: [{ messageId: 'invalidVariableName' }],
},
{
code: 'const [myValue, setMyValue] = useAtomComponentState(variableComponentState);',
output: 'const [variable, setVariable] = useAtomComponentState(variableComponentState);',
errors: [
{ messageId: 'invalidVariableName' },
{ messageId: 'invalidSetterName' },
],
},
{
code: 'const myValue = useAtomFamilyStateValue(variableFamilyState, key);',
output: 'const variable = useAtomFamilyStateValue(variableFamilyState, key);',
errors: [{ messageId: 'invalidVariableName' }],
},
{
code: 'const myValue = useAtomComponentFamilyStateValue(variableComponentFamilyState, key);',
output: 'const variable = useAtomComponentFamilyStateValue(variableComponentFamilyState, key);',
errors: [{ messageId: 'invalidVariableName' }],
},
{
code: 'const [myValue, setMyValue] = useAtomComponentFamilyState(variableComponentFamilyState, key);',
output: 'const [variable, setVariable] = useAtomComponentFamilyState(variableComponentFamilyState, key);',
errors: [
{ messageId: 'invalidVariableName' },
{ messageId: 'invalidSetterName' },
],
},
{
code: 'const myValue = useSetAtomState(variableState);',
output: 'const setVariable = useSetAtomState(variableState);',
errors: [{ messageId: 'invalidSetterName' }],
},
{
code: 'const myValue = useSetAtomComponentState(variableComponentState);',
output: 'const setVariable = useSetAtomComponentState(variableComponentState);',
errors: [{ messageId: 'invalidSetterName' }],
},
{
code: 'const myValue = useSetAtomFamilyState(variableFamilyState, key);',
output: 'const setVariable = useSetAtomFamilyState(variableFamilyState, key);',
errors: [{ messageId: 'invalidSetterName' }],
},
{
code: 'const myValue = useSetAtomComponentFamilyState(variableComponentFamilyState, key);',
output: 'const setVariable = useSetAtomComponentFamilyState(variableComponentFamilyState, key);',
errors: [{ messageId: 'invalidSetterName' }],
},
{
code: "const variableSorts = useAtomFamilyStateValue(variableFamilyState, 'views');",
output: "const variable = useAtomFamilyStateValue(variableFamilyState, 'views');",
errors: [{ messageId: 'invalidVariableName' }],
},
],
});

View File

@@ -0,0 +1,250 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'matching-state-variable';
const VALUE_HOOKS = [
'useAtomStateValue',
'useAtomComponentStateValue',
'useAtomFamilyStateValue',
'useAtomComponentFamilyStateValue',
];
const STATE_HOOKS = [
'useAtomState',
'useAtomComponentState',
'useAtomComponentFamilyState',
];
const SETTER_HOOKS = [
'useSetAtomState',
'useSetAtomComponentState',
'useSetAtomFamilyState',
'useSetAtomComponentFamilyState',
];
const FAMILY_HOOKS = new Set([
'useAtomFamilyStateValue',
'useAtomComponentFamilyStateValue',
'useAtomComponentFamilyState',
'useSetAtomFamilyState',
'useSetAtomComponentFamilyState',
]);
const ALL_HOOKS = [...VALUE_HOOKS, ...STATE_HOOKS, ...SETTER_HOOKS];
const SUFFIX_PATTERN =
/(ComponentFamilyState|ComponentState|ScopedFamilyState|FamilyState|ScopedState|ScopedSelector|Selector|State)$/;
const getExpectedBaseName = (stateArgName: string): string =>
stateArgName.replace(SUFFIX_PATTERN, '');
const getExpectedSetterName = (baseName: string): string =>
`set${baseName.charAt(0).toUpperCase()}${baseName.slice(1)}`;
// Family hooks may suffix the string-literal key to disambiguate multiple
// reads of the same family in one scope (e.g. metadataStore / metadataStoreViews).
const getFamilyKeyVariants = (
hookName: string,
args: ReadonlyArray<any> | undefined,
): string[] => {
if (!FAMILY_HOOKS.has(hookName) || !args) {
return [];
}
const familyKeyArg = args[1];
if (
familyKeyArg?.type !== 'Literal' ||
typeof familyKeyArg.value !== 'string' ||
familyKeyArg.value.length === 0
) {
return [];
}
const keyValue = familyKeyArg.value;
return [`${keyValue.charAt(0).toUpperCase()}${keyValue.slice(1)}`];
};
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Ensure state value and setter are named after their atom name',
},
fixable: 'code',
schema: [],
messages: {
invalidVariableName:
"Invalid usage of {{ hookName }}: the variable should be named '{{ expectedName }}' but found '{{ actualName }}'.",
invalidSetterName:
"Invalid usage of {{ hookName }}: Expected setter '{{ expectedName }}' but found '{{ actualName }}'.",
},
},
create: (context) => {
return {
VariableDeclarator: (node: any) => {
if (
node?.init?.type !== 'CallExpression' ||
node.init.callee?.type !== 'Identifier' ||
!ALL_HOOKS.includes(node.init.callee.name)
) {
return;
}
const hookName = node.init.callee.name;
const stateNameBase =
node.init.arguments[0]?.type === 'Identifier'
? node.init.arguments[0].name
: undefined;
if (!stateNameBase) {
return;
}
const expectedVariableNameBase = getExpectedBaseName(stateNameBase);
const familyKeySuffixes = getFamilyKeyVariants(
hookName,
node.init.arguments,
);
const acceptedVariableNames = [
expectedVariableNameBase,
...familyKeySuffixes.map(
(suffix) => `${expectedVariableNameBase}${suffix}`,
),
];
const acceptedSetterNames = acceptedVariableNames.map(
getExpectedSetterName,
);
if (SETTER_HOOKS.includes(hookName)) {
if (node.id?.type === 'Identifier') {
const actualName = node.id.name;
if (!acceptedSetterNames.includes(actualName)) {
context.report({
node,
messageId: 'invalidSetterName',
data: {
hookName: stateNameBase,
actualName,
expectedName: acceptedSetterNames[0],
},
fix: (fixer) =>
fixer.replaceText(node.id, acceptedSetterNames[0]),
});
}
}
return;
}
if (VALUE_HOOKS.includes(hookName)) {
if (node.id?.type === 'Identifier') {
const actualName = node.id.name;
if (!acceptedVariableNames.includes(actualName)) {
context.report({
node,
messageId: 'invalidVariableName',
data: {
actualName,
expectedName: acceptedVariableNames[0],
hookName: stateNameBase,
callee: hookName,
},
fix: (fixer) =>
fixer.replaceText(node.id, acceptedVariableNames[0]),
});
}
}
return;
}
if (node.id?.type === 'Identifier') {
const actualVariableName = node.id.name;
if (!acceptedVariableNames.includes(actualVariableName)) {
context.report({
node,
messageId: 'invalidVariableName',
data: {
actualName: actualVariableName,
expectedName: acceptedVariableNames[0],
hookName: stateNameBase,
callee: hookName,
},
fix: (fixer) =>
fixer.replaceText(node.id, acceptedVariableNames[0]),
});
}
return;
}
if (node.id.type === 'ArrayPattern') {
const actualVariableName =
node.id.elements?.[0]?.type === 'Identifier'
? node.id.elements[0].name
: undefined;
if (
actualVariableName &&
!acceptedVariableNames.includes(actualVariableName)
) {
context.report({
node,
messageId: 'invalidVariableName',
data: {
actualName: actualVariableName,
expectedName: acceptedVariableNames[0],
hookName: stateNameBase,
callee: hookName,
},
fix: (fixer) => {
if (node.id.type === 'ArrayPattern') {
return fixer.replaceText(
node.id.elements[0] as any,
acceptedVariableNames[0],
);
}
return null;
},
});
}
if (node.id.elements[1]?.type === 'Identifier') {
const actualSetterName = node.id.elements[1].name;
if (!acceptedSetterNames.includes(actualSetterName)) {
context.report({
node,
messageId: 'invalidSetterName',
data: {
hookName: stateNameBase,
actualName: actualSetterName,
expectedName: acceptedSetterNames[0],
},
fix: (fixer) => {
if (node.id.type === 'ArrayPattern') {
return fixer.replaceText(
node.id.elements[1]!,
acceptedSetterNames[0],
);
}
return null;
},
});
}
}
}
},
};
},
});

View File

@@ -0,0 +1,23 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './max-consts-per-file';
const max = 1;
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: 'const A = 1;',
options: [{ max }],
},
],
invalid: [
{
code: 'const NAME_A = 1;\nconst NAME_B = 2;',
options: [{ max }],
errors: [{ messageId: 'tooManyConstants', data: { max } }],
},
],
});

View File

@@ -0,0 +1,43 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'max-consts-per-file';
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Ensure there are at most a specified number of const declarations constant file',
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
max: { type: 'integer', minimum: 0 },
},
additionalProperties: false,
},
],
messages: {
tooManyConstants:
'Only a maximum of ({{ max }}) const declarations are allowed in this file.',
},
},
create: (context) => {
const [{ max }] = context.options as [{ max: number }];
let constCount = 0;
return {
VariableDeclaration: (node: any) => {
constCount++;
if (constCount > max) {
context.report({
node,
messageId: 'tooManyConstants',
data: { max },
});
}
},
};
},
});

View File

@@ -0,0 +1,102 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './no-data-mutation-in-fast-instance-command';
const ruleTester = new RuleTester();
const BASE =
'/project/packages/twenty-server/src/database/commands/upgrade-version-command';
const FAST_FILE = `${BASE}/2-13/2-13-instance-command-fast-1781277453604-rename-flag.ts`;
const SLOW_FILE = `${BASE}/2-13/2-13-instance-command-slow-1781277480000-backfill-flag.ts`;
ruleTester.run(RULE_NAME, rule, {
valid: [
// Schema changes are exactly what a fast command is for.
{
filename: FAST_FILE,
code: `class C { async up(q) { await q.query('ALTER TABLE "core"."x" ADD COLUMN IF NOT EXISTS "y" boolean NOT NULL DEFAULT true'); } }`,
},
// A foreign key clause contains DELETE/UPDATE but is not statement-leading DML.
{
filename: FAST_FILE,
code: `class C { async up(q) { await q.query('ALTER TABLE "core"."a" ADD CONSTRAINT "fk" FOREIGN KEY ("bId") REFERENCES "core"."b"("id") ON DELETE CASCADE ON UPDATE NO ACTION'); } }`,
},
// A column named "updatedAt" must not trip the UPDATE keyword.
{
filename: FAST_FILE,
code: `class C { async up(q) { await q.query('ALTER TABLE "core"."x" ADD COLUMN "updatedAt" timestamptz'); } }`,
},
// A read-only CTE has no data mutation.
{
filename: FAST_FILE,
code: `class C { async up(q) { await q.query(\`WITH recent AS (SELECT "id" FROM "core"."x") SELECT count(*) FROM recent\`); } }`,
},
// A read-only CTE that only mentions a keyword in a string is not flagged.
{
filename: FAST_FILE,
code: `class C { async up(q) { await q.query(\`WITH note AS (SELECT 'needs update later' AS msg) SELECT msg FROM note\`); } }`,
},
// A keyword after a parenthesis inside a string literal is not flagged.
{
filename: FAST_FILE,
code: `class C { async up(q) { await q.query(\`WITH note AS (SELECT 'reminder: (UPDATE later)' AS msg) SELECT msg FROM note\`); } }`,
},
// Rollback DML lives in down() and is allowed (incl. via a CTE).
{
filename: FAST_FILE,
code: `class C { async down(q) { await q.query(\`UPDATE "core"."x" SET "y" = false WHERE "z" = true\`); } }`,
},
{
filename: FAST_FILE,
code: `class C { async down(q) { await q.query(\`WITH ids AS (SELECT "id" FROM "core"."x") UPDATE "core"."x" SET "y" = true\`); } }`,
},
// down() written as an arrow-function property is also recognized.
{
filename: FAST_FILE,
code: `class C { down = async (q) => { await q.query('DELETE FROM "core"."x" WHERE 1 = 1'); }; }`,
},
// A slow command is the correct home for a data migration.
{
filename: SLOW_FILE,
code: `class C { async runDataMigration(ds) { await ds.query(\`UPDATE "core"."x" SET "y" = false\`); } }`,
},
// Files outside the upgrade-command tree are ignored.
{
filename: '/project/packages/twenty-front/src/foo.ts',
code: `class C { async up(q) { await q.query(\`UPDATE "x" SET "y" = 1\`); } }`,
},
],
invalid: [
// The incident pattern: bulk UPDATE in up().
{
filename: FAST_FILE,
code: `class C { async up(q) { await q.query(\`UPDATE "core"."fieldMetadata" SET "isUIEditable" = false WHERE "isUIReadOnly" = true\`); } }`,
errors: [{ messageId: 'dataMutationInFastInstanceCommand' }],
},
// INSERT in up().
{
filename: FAST_FILE,
code: `class C { async up(q) { await q.query('INSERT INTO "core"."x" ("id") VALUES (1)'); } }`,
errors: [{ messageId: 'dataMutationInFastInstanceCommand' }],
},
// DELETE in up().
{
filename: FAST_FILE,
code: `class C { async up(q) { await q.query('DELETE FROM "core"."x" WHERE "t" = 1'); } }`,
errors: [{ messageId: 'dataMutationInFastInstanceCommand' }],
},
// Data mutation moved into a helper called by up() is still forbidden.
{
filename: FAST_FILE,
code: `class C { async backfill(q) { await q.query('DELETE FROM "core"."x" WHERE 1 = 1'); } }`,
errors: [{ messageId: 'dataMutationInFastInstanceCommand' }],
},
// CTE-wrapped data mutation in up().
{
filename: FAST_FILE,
code: `class C { async up(q) { await q.query(\`WITH ids AS (SELECT "id" FROM "core"."x") UPDATE "core"."x" SET "y" = false\`); } }`,
errors: [{ messageId: 'dataMutationInFastInstanceCommand' }],
},
],
});

View File

@@ -0,0 +1,143 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'no-data-mutation-in-fast-instance-command';
const UPGRADE_COMMAND_MARKER = 'upgrade-version-command/';
const FAST_INSTANCE_COMMAND_SEGMENT = 'instance-command-fast-';
const SKIPPED_FILE_REGEX = /\.(spec|test)\.ts$/;
const DATA_MUTATION_STATEMENT_REGEX = /^(UPDATE|INSERT|DELETE|MERGE)\b/i;
const CTE_DATA_MUTATION_REGEX =
/^WITH\b[\s\S]*[()]\s*(UPDATE|INSERT|DELETE|MERGE)\b/i;
const isFastInstanceCommandFile = (filename: string): boolean => {
const markerIndex = filename.indexOf(UPGRADE_COMMAND_MARKER);
if (markerIndex === -1) {
return false;
}
const basename =
filename
.slice(markerIndex + UPGRADE_COMMAND_MARKER.length)
.split('/')
.pop() ?? '';
if (SKIPPED_FILE_REGEX.test(basename)) {
return false;
}
return basename.includes(FAST_INSTANCE_COMMAND_SEGMENT);
};
const isInsideDownMethod = (node: any): boolean => {
let current = node.parent;
while (current) {
if (
(current.type === 'MethodDefinition' ||
current.type === 'PropertyDefinition') &&
current.key?.type === 'Identifier' &&
current.key.name === 'down'
) {
return true;
}
current = current.parent;
}
return false;
};
const getSqlText = (argument: any): string | null => {
if (argument.type === 'Literal' && typeof argument.value === 'string') {
return argument.value;
}
if (argument.type === 'TemplateLiteral') {
return argument.quasis.map((quasi: any) => quasi.value.raw).join(' ');
}
return null;
};
const findDataMutationKeyword = (sql: string): string | null => {
const normalized = sql
.replace(/--[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/'(?:[^']|'')*'/g, "''")
.replace(/"(?:[^"]|"")*"/g, '""');
for (const statement of normalized.split(';')) {
const trimmed = statement.trim();
const match =
trimmed.match(DATA_MUTATION_STATEMENT_REGEX) ??
trimmed.match(CTE_DATA_MUTATION_REGEX);
if (match) {
return match[1].toUpperCase();
}
}
return null;
};
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Disallow data mutations (UPDATE/INSERT/DELETE) in a fast instance command outside down(); backfills belong in a slow instance command',
},
schema: [],
messages: {
dataMutationInFastInstanceCommand:
"Fast instance commands must not run data mutations outside down() (found '{{ keyword }}'). A bulk write held in the same transaction as ADD/ALTER COLUMN keeps an ACCESS EXCLUSIVE lock and can stall reads during the deploy. Put backfills in a slow instance command's runDataMigration(); rollback writes belong in down().",
},
},
create: (context) => {
if (!isFastInstanceCommandFile(context.filename)) {
return {};
}
return {
CallExpression: (node: any) => {
const callee = node.callee;
if (
callee?.type !== 'MemberExpression' ||
callee.property?.type !== 'Identifier' ||
callee.property.name !== 'query'
) {
return;
}
if (isInsideDownMethod(node)) {
return;
}
const sqlArgument = node.arguments?.[0];
if (!sqlArgument) {
return;
}
const sql = getSqlText(sqlArgument);
if (sql === null) {
return;
}
const keyword = findDataMutationKeyword(sql);
if (keyword) {
context.report({
node: sqlArgument,
messageId: 'dataMutationInFastInstanceCommand',
data: { keyword },
});
}
},
};
},
});

View File

@@ -0,0 +1,84 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './no-direct-atom-family-in-selector';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: `
const mySelector = createAtomComponentSelector({
key: 'mySelector',
get: ({ instanceId }) => ({ get }) => {
const value = get(someComponentState, { instanceId });
return value;
},
componentInstanceContext: MyContext,
});
`,
},
{
code: `
const mySelector = createAtomComponentFamilySelector({
key: 'mySelector',
get: ({ instanceId, familyKey }) => ({ get }) => {
const record = get(recordStoreFamilyState, familyKey.recordId);
return record;
},
componentInstanceContext: MyContext,
});
`,
},
{
code: `
const atom = someState.atomFamily({ instanceId: 'test' });
`,
},
],
invalid: [
{
code: `
const mySelector = createAtomComponentSelector({
key: 'mySelector',
get: ({ instanceId }) => ({ get }) => {
const atom = someState.atomFamily({ instanceId });
return get(atom);
},
componentInstanceContext: MyContext,
});
`,
errors: [{ messageId: 'noDirectAtomFamilyInSelector' }],
},
{
code: `
const mySelector = createAtomComponentFamilySelector({
key: 'mySelector',
get: ({ instanceId, familyKey }) => ({ get }) => {
const selector = someSelector.selectorFamily({ instanceId });
return get(selector);
},
componentInstanceContext: MyContext,
});
`,
errors: [{ messageId: 'noDirectAtomFamilyInSelector' }],
},
{
code: `
const mySelector = createAtomComponentFamilySelector({
key: 'mySelector',
get: ({ instanceId, familyKey }) => ({ get }) => {
const a = someState.atomFamily({ instanceId });
const b = otherState.selectorFamily({ instanceId });
return get(a) + get(b);
},
componentInstanceContext: MyContext,
});
`,
errors: [
{ messageId: 'noDirectAtomFamilyInSelector' },
{ messageId: 'noDirectAtomFamilyInSelector' },
],
},
],
});

View File

@@ -0,0 +1,71 @@
import { defineRule } from '@oxlint/plugins';
import { isNodeInsideAncestor } from '../utils/isNodeInsideAncestor';
export const RULE_NAME = 'no-direct-atom-family-in-selector';
const SELECTOR_FACTORY_NAMES = [
'createAtomComponentSelector',
'createAtomComponentFamilySelector',
];
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Disallow direct .atomFamily() or .selectorFamily() calls inside component selector get callbacks',
},
messages: {
noDirectAtomFamilyInSelector:
'Do not call `.atomFamily()` or `.selectorFamily()` directly inside component selector `get` callbacks. Use the provided `get` helper instead, because it is the cleanest API.',
},
schema: [],
},
create: (context) => {
const selectorGetNodes: any[] = [];
return {
CallExpression: (node: any) => {
if (
node.callee.type === 'Identifier' &&
SELECTOR_FACTORY_NAMES.includes(node.callee.name)
) {
const configArg = node.arguments[0];
if (!configArg || configArg.type !== 'ObjectExpression') {
return;
}
const getProperty = configArg.properties.find(
(prop: any) =>
prop.type === 'Property' &&
prop.key.type === 'Identifier' &&
prop.key.name === 'get',
);
if (getProperty) {
selectorGetNodes.push(getProperty.value);
}
return;
}
if (
node.callee.type === 'MemberExpression' &&
node.callee.property?.type === 'Identifier' &&
/^(atomFamily|selectorFamily)$/.test(node.callee.property.name)
) {
for (const getNode of selectorGetNodes) {
if (isNodeInsideAncestor(node.callee, getNode)) {
context.report({
node: node.callee,
messageId: 'noDirectAtomFamilyInSelector',
});
break;
}
}
}
},
};
},
});

View File

@@ -0,0 +1,69 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './no-hardcoded-colors';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: 'const color = theme.background.secondary;',
},
],
invalid: [
{
code: 'const color = "rgb(154,205,50)";',
errors: [
{
messageId: 'hardcodedColor',
},
],
},
{
code: 'const color = { test: "rgb(154,205,50)", test2: "#ADFF2F" }',
errors: [
{
messageId: 'hardcodedColor',
},
{
messageId: 'hardcodedColor',
},
],
},
{
code: 'const color = { test: `rgb(${r},${g},${b})`, test2: `#ADFF${test}` }',
errors: [
{
messageId: 'hardcodedColor',
},
{
messageId: 'hardcodedColor',
},
],
},
{
code: 'const color = "#ADFF2F";',
errors: [
{
messageId: 'hardcodedColor',
},
],
},
{
code: 'const myCss = css`color: #123; background-color: ${theme.background.secondary};`',
errors: [
{
messageId: 'hardcodedColor',
},
],
},
{
code: 'const myCss = styled.div`color: ${({ theme }) => theme.font.color.primary};flex-shrink: 0;background-color: #123;text-overflow: ellipsis;white-space: nowrap;max-width: 100%;`',
errors: [
{
messageId: 'hardcodedColor',
},
],
},
],
});

View File

@@ -0,0 +1,46 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'no-hardcoded-colors';
export const rule = defineRule({
meta: {
docs: {
description:
'Do not use hardcoded RGBA or Hex colors. Please use a color from the theme file.',
},
messages: {
hardcodedColor:
'Hardcoded color {{ color }} found. Please use a color from the theme file.',
},
type: 'suggestion',
schema: [],
fixable: 'code',
},
create: (context) => {
const testHardcodedColor = (literal: any) => {
const colorRegex = /(?:rgba?\()|(?:#[0-9a-fA-F]{3,6})\b/i;
if (literal.type === 'Literal' && typeof literal.value === 'string') {
if (colorRegex.test(literal.value))
context.report({
node: literal,
messageId: 'hardcodedColor',
data: { color: literal.value },
});
} else if (literal.type === 'TemplateLiteral') {
for (const quasi of literal.quasis) {
if (colorRegex.test(quasi.value.raw))
context.report({
node: literal,
messageId: 'hardcodedColor',
data: { color: quasi.value.raw },
});
}
}
};
return {
Literal: (literal: any) => testHardcodedColor(literal),
TemplateLiteral: (templateLiteral: any) =>
testHardcodedColor(templateLiteral),
};
},
});

View File

@@ -0,0 +1,64 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './no-icu-escaping-apostrophe';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: 'const message = msg`Cron pattern "${pattern}" is invalid`;',
filename: 'compute-cron-pattern.ts',
},
{
code: "const message = t`It's a nice ${thing}`;",
filename: 'greeting.ts',
},
{
code: "const message = t`a ''${value}'' b`;",
filename: 'escaped.ts',
},
{
code: "const message = t`quoted at the end ${value}'`;",
filename: 'trailing.ts',
},
{
code: "const query = sql`select '${column}' from t`;",
filename: 'query.ts',
},
{
code: "const message = t`no placeholder here '{}'`;",
filename: 'literal.ts',
},
],
invalid: [
{
code: "const message = msg`Cron pattern '${pattern}' is invalid`;",
errors: [{ messageId: 'escapingApostrophe' }],
filename: 'compute-cron-pattern.ts',
},
{
code: "const message = t`domain '${domainName}'. Expect 1`;",
errors: [{ messageId: 'escapingApostrophe' }],
filename: 'dns-manager.service.ts',
},
{
code: "const message = i18n.t`domain '${domainName}' is taken`;",
errors: [{ messageId: 'escapingApostrophe' }],
filename: 'member.ts',
},
{
code: "const message = defineMessage`from '${before}' to '${after}'`;",
errors: [
{ messageId: 'escapingApostrophe' },
{ messageId: 'escapingApostrophe' },
],
filename: 'role.ts',
},
{
code: "const message = t`a '''${value}''' b`;",
errors: [{ messageId: 'escapingApostrophe' }],
filename: 'odd-run.ts',
},
],
});

View File

@@ -0,0 +1,73 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'no-icu-escaping-apostrophe';
const MESSAGE_TAG_NAMES = ['t', 'msg', 'defineMessage'];
const getTagName = (tag: any): string | undefined => {
if (tag.type === 'Identifier') {
return tag.name;
}
// useLingui() hands the macro back on an object, so `i18n.t` and `_.t` reach
// the extractor exactly as a bare tag does.
if (tag.type === 'MemberExpression' && tag.property.type === 'Identifier') {
return tag.property.name;
}
return undefined;
};
const countTrailingApostrophes = (text: string): number => {
const trailing = /'*$/.exec(text);
return trailing === null ? 0 : trailing[0].length;
};
// ICU reads an apostrophe placed directly before `{` as the start of a quoted
// literal, so `'{name}'` prints the placeholder instead of substituting it.
// Apostrophes pair up first: an even run is that many escaped literal
// apostrophes, and only an odd run leaves one to swallow the placeholder.
const escapesFollowingPlaceholder = (text: string): boolean =>
countTrailingApostrophes(text) % 2 === 1;
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Disallow an apostrophe directly before a Lingui placeholder, which ICU reads as an escape.',
},
messages: {
escapingApostrophe:
"An apostrophe directly before a placeholder escapes it: ICU renders the placeholder as literal text instead of substituting the value. Quote the value with double quotes, or double the apostrophe ('') to print one.",
},
schema: [],
},
create: (context) => ({
TaggedTemplateExpression: (node: any) => {
const tagName = getTagName(node.tag);
if (tagName === undefined || !MESSAGE_TAG_NAMES.includes(tagName)) {
return;
}
node.quasi.quasis.forEach((quasi: any, index: number) => {
const isFollowedByPlaceholder =
node.quasi.expressions[index] !== undefined;
if (!isFollowedByPlaceholder) {
return;
}
const text = quasi.value.cooked ?? quasi.value.raw;
if (!escapesFollowingPlaceholder(text)) {
return;
}
context.report({ node: quasi, messageId: 'escapingApostrophe' });
});
},
}),
});

View File

@@ -0,0 +1,97 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './no-jotai-store-in-selector';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: `
const mySelector = createAtomComponentSelector({
key: 'mySelector',
get: ({ instanceId }) => ({ get }) => {
const value = get(someComponentState, { instanceId });
return value;
},
componentInstanceContext: MyContext,
});
`,
},
{
code: `
const mySelector = createAtomComponentFamilySelector({
key: 'mySelector',
get: ({ instanceId, familyKey }) => ({ get }) => {
const messages = get(messagesComponentState, { instanceId });
return messages.find((m) => m.id === familyKey.messageId);
},
componentInstanceContext: MyContext,
});
`,
},
{
code: `
const value = jotaiStore.get(someState.atom);
`,
},
],
invalid: [
{
code: `
const mySelector = createAtomComponentSelector({
key: 'mySelector',
get: ({ instanceId }) => ({ get }) => {
const value = jotaiStore.get(someState.atom);
return value;
},
componentInstanceContext: MyContext,
});
`,
errors: [{ messageId: 'noJotaiStoreInSelector' }],
},
{
code: `
const mySelector = createAtomComponentFamilySelector({
key: 'mySelector',
get: ({ instanceId, familyKey }) => ({ get }) => {
const value = jotaiStore.get(someState.atom);
return value;
},
componentInstanceContext: MyContext,
});
`,
errors: [{ messageId: 'noJotaiStoreInSelector' }],
},
{
code: `
const mySelector = createAtomComponentSelector({
key: 'mySelector',
get: ({ instanceId }) => ({ get }) => {
jotaiStore.set(someState.atom, 'value');
return 'value';
},
componentInstanceContext: MyContext,
});
`,
errors: [{ messageId: 'noJotaiStoreInSelector' }],
},
{
code: `
const mySelector = createAtomComponentFamilySelector({
key: 'mySelector',
get: ({ instanceId, familyKey }) => ({ get }) => {
const a = jotaiStore.get(stateA.atom);
const b = jotaiStore.get(stateB.atom);
return a + b;
},
componentInstanceContext: MyContext,
});
`,
errors: [
{ messageId: 'noJotaiStoreInSelector' },
{ messageId: 'noJotaiStoreInSelector' },
],
},
],
});

View File

@@ -0,0 +1,72 @@
import { defineRule } from '@oxlint/plugins';
import { isNodeInsideAncestor } from '../utils/isNodeInsideAncestor';
export const RULE_NAME = 'no-jotai-store-in-selector';
const SELECTOR_FACTORY_NAMES = [
'createAtomComponentSelector',
'createAtomComponentFamilySelector',
];
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Disallow using jotaiStore inside component selector get callbacks',
},
messages: {
noJotaiStoreInSelector:
'Do not use `jotaiStore` inside component selector `get` callbacks. Use the provided `get` helper from the callback argument instead, because it breaks the reactivity loop.',
},
schema: [],
},
create: (context) => {
const selectorGetNodes: any[] = [];
return {
CallExpression: (node: any) => {
if (
node.callee.type !== 'Identifier' ||
!SELECTOR_FACTORY_NAMES.includes(node.callee.name)
) {
return;
}
const configArg = node.arguments[0];
if (!configArg || configArg.type !== 'ObjectExpression') {
return;
}
const getProperty = configArg.properties.find(
(prop: any) =>
prop.type === 'Property' &&
prop.key.type === 'Identifier' &&
prop.key.name === 'get',
);
if (getProperty) {
selectorGetNodes.push(getProperty.value);
}
},
MemberExpression: (node: any) => {
if (
node.object?.type === 'Identifier' &&
node.object.name === 'jotaiStore'
) {
for (const getNode of selectorGetNodes) {
if (isNodeInsideAncestor(node, getNode)) {
context.report({
node,
messageId: 'noJotaiStoreInSelector',
});
break;
}
}
}
},
};
},
});

View File

@@ -0,0 +1,95 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './no-miscased-acronym-in-message';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: 'const label = msg`Avatar URL`;',
filename: 'compute-workspace-member.ts',
},
{
code: 'const label = t`ACS URL copied to clipboard`;',
filename: 'saml-form.tsx',
},
{
code: "const label = msg({ message: `ID`, context: 'fieldMetadata.label' });",
filename: 'compute-company.ts',
},
// The acronym is the tail of a field name the sentence is naming, so the
// lowercase spelling is the correct one.
{
code: 'const message = msg`${fieldName} and ${fieldName}Id cannot be both provided.`;',
filename: 'connect-query.ts',
},
{
code: 'const label = t`Identity provider`;',
filename: 'sso.tsx',
},
{
code: 'const label = t`Urlaub buchen`;',
filename: 'de.tsx',
},
// Not a message tag, so not this rule's business.
{
code: 'const query = sql`select Id from t`;',
filename: 'query.ts',
},
{
code: 'const heading = <div>Avatar Url</div>;',
filename: 'header.tsx',
},
],
invalid: [
{
code: 'const label = msg`Avatar Url`;',
errors: [{ messageId: 'miscasedAcronym' }],
filename: 'compute-workspace-member.ts',
},
{
code: 'const label = t`ACS Url`;',
errors: [{ messageId: 'miscasedAcronym' }],
filename: 'saml-form.tsx',
},
{
code: 'const label = i18n.t`Redirect Url copied to clipboard`;',
errors: [{ messageId: 'miscasedAcronym' }],
filename: 'redirect.tsx',
},
{
code: "const label = msg({ message: `Message Channel Id`, context: 'fieldMetadata.label' });",
errors: [{ messageId: 'miscasedAcronym' }],
filename: 'compute-message-channel.ts',
},
{
code: "const label = msg({ message: 'Json object to provide steps' });",
errors: [{ messageId: 'miscasedAcronym' }],
filename: 'compute-workflow-version.ts',
},
{
code: 'const label = i18n.t({ message: `Avatar Url` });',
errors: [{ messageId: 'miscasedAcronym' }],
filename: 'avatar.ts',
},
{
code: 'const label = msg`Json value for event Id`;',
errors: [
{ messageId: 'miscasedAcronym' },
{ messageId: 'miscasedAcronym' },
],
filename: 'compute-timeline-activity.ts',
},
{
code: 'const heading = <Trans>Avatar Url</Trans>;',
errors: [{ messageId: 'miscasedAcronym' }],
filename: 'avatar.tsx',
},
{
code: 'const label = defineMessage`Last published Version Id`;',
errors: [{ messageId: 'miscasedAcronym' }],
filename: 'compute-workflow.ts',
},
],
});

View File

@@ -0,0 +1,193 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'no-miscased-acronym-in-message';
const MESSAGE_TAG_NAMES = ['t', 'msg', 'defineMessage'];
const CORRECT_CASING: Record<string, string> = {
Api: 'API',
Apis: 'APIs',
Cdn: 'CDN',
Cname: 'CNAME',
Cors: 'CORS',
Crm: 'CRM',
Csv: 'CSV',
Dkim: 'DKIM',
Dmarc: 'DMARC',
Dns: 'DNS',
Html: 'HTML',
Http: 'HTTP',
Https: 'HTTPS',
Id: 'ID',
Ids: 'IDs',
Imap: 'IMAP',
Json: 'JSON',
Jwt: 'JWT',
Mfa: 'MFA',
Oauth: 'OAuth',
Otp: 'OTP',
Pdf: 'PDF',
Saml: 'SAML',
Sdk: 'SDK',
Smtp: 'SMTP',
Sql: 'SQL',
Sso: 'SSO',
Ssl: 'SSL',
Tls: 'TLS',
Totp: 'TOTP',
Ttl: 'TTL',
Uid: 'UID',
Url: 'URL',
Urls: 'URLs',
Uuid: 'UUID',
Xml: 'XML',
Yaml: 'YAML',
};
// `}` joins the boundary characters so that `${fieldName}Id` is left alone: the
// acronym there is the tail of a code identifier the sentence is naming, not an
// English word. The trailing guard rejects `Identity` and `Urls` alike - `Urls`
// only matches because the map lists it in its own right.
const MISCASED_ACRONYM_REGEX = new RegExp(
`(?<![A-Za-z}])(${Object.keys(CORRECT_CASING).join('|')})(?![a-z])`,
'g',
);
const getTagName = (tag: any): string | undefined => {
if (tag.type === 'Identifier') {
return tag.name;
}
// useLingui() hands the macro back on an object, so `i18n.t` and `_.t` reach
// the extractor exactly as a bare tag does.
if (tag.type === 'MemberExpression' && tag.property.type === 'Identifier') {
return tag.property.name;
}
return undefined;
};
const findMiscasedAcronyms = (text: string): string[] => [
...new Set([...text.matchAll(MISCASED_ACRONYM_REGEX)].map(([word]) => word)),
];
// A template literal reaches the rule already split at its placeholders, so the
// `Id` of `${fieldName}Id` opens its own chunk with nothing in front of it and
// the boundary guard would wave it through. Putting the brace back restores the
// context the split removed.
const withPrecedingPlaceholder = (
text: string,
isPrecededByPlaceholder: boolean,
): string => (isPrecededByPlaceholder ? `}${text}` : text);
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Disallow title-cased acronyms in translatable source strings, which translators render correctly and Crowdin then reports as a mismatch.',
},
messages: {
miscasedAcronym:
'Write {{corrected}} rather than {{found}} in a translatable string. Translators render the acronym correctly in their own language, so the miscased English is what Crowdin reports as a QA failure - and it ships to users as a wrong label.',
},
schema: [],
},
create: (context) => {
const reportMiscasedAcronyms = (
node: any,
text: string,
isPrecededByPlaceholder = false,
) => {
for (const found of findMiscasedAcronyms(
withPrecedingPlaceholder(text, isPrecededByPlaceholder),
)) {
context.report({
node,
messageId: 'miscasedAcronym',
data: { found, corrected: CORRECT_CASING[found] },
});
}
};
return {
TaggedTemplateExpression: (node: any) => {
const tagName = getTagName(node.tag);
if (tagName === undefined || !MESSAGE_TAG_NAMES.includes(tagName)) {
return;
}
node.quasi.quasis.forEach((quasi: any, index: number) => {
reportMiscasedAcronyms(
quasi,
quasi.value.cooked ?? quasi.value.raw,
index > 0,
);
});
},
// The descriptor form the standard field metadata uses, and where this
// defect collected.
CallExpression: (node: any) => {
const calleeName = getTagName(node.callee);
if (
calleeName === undefined ||
!MESSAGE_TAG_NAMES.includes(calleeName)
) {
return;
}
const [argument] = node.arguments;
if (argument?.type !== 'ObjectExpression') {
return;
}
for (const property of argument.properties) {
if (
property.type !== 'Property' ||
property.key.type !== 'Identifier' ||
property.key.name !== 'message'
) {
continue;
}
const { value } = property;
if (value.type === 'TemplateLiteral') {
value.quasis.forEach((quasi: any, index: number) => {
reportMiscasedAcronyms(
quasi,
quasi.value.cooked ?? quasi.value.raw,
index > 0,
);
});
}
if (value.type === 'Literal' && typeof value.value === 'string') {
reportMiscasedAcronyms(value, value.value);
}
}
},
// Trans children only: plain JSX text is not a translatable string, so
// flagging it here would report a different problem under this rule's name.
JSXElement: (node: any) => {
if (
node.openingElement.name.type !== 'JSXIdentifier' ||
node.openingElement.name.name !== 'Trans'
) {
return;
}
for (const child of node.children) {
if (child.type === 'JSXText') {
reportMiscasedAcronyms(child, child.value);
}
}
},
};
},
});

View File

@@ -0,0 +1,41 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './no-navigate-prefer-link';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: 'if(someVar) { navigate("/"); }',
},
{
code: '<Link to="/"><Button>Click me</Button></Link>',
filename: 'test.tsx',
},
{
code: '<Button onClick={() =>{ navigate("/"); doSomething(); }} />',
filename: 'test.tsx',
},
],
invalid: [
{
code: '<Button onClick={() => navigate("/")} />',
errors: [
{
messageId: 'preferLink',
},
],
filename: 'test.tsx',
},
{
code: '<Button onClick={() => { navigate("/");} } />',
errors: [
{
messageId: 'preferLink',
},
],
filename: 'test.tsx',
},
],
});

View File

@@ -0,0 +1,92 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'no-navigate-prefer-link';
export const rule = defineRule({
meta: {
type: 'suggestion',
docs: {
description:
'Discourage usage of navigate() where a simple <Link> component would suffice.',
},
messages: {
preferLink: 'Use <Link> instead of navigate() for pure navigation.',
},
schema: [],
},
create: (context) => {
const functionMap: Record<string, any> = {};
const checkFunctionBodyHasSingleNavigateCall = (func: any) => {
if (
func.body.type === 'CallExpression' &&
func.body.callee.type === 'Identifier' &&
func.body.callee.name === 'navigate'
) {
return true;
}
if (
func.body.type === 'BlockStatement' &&
func.body.body.length === 1 &&
func.body.body[0].type === 'ExpressionStatement' &&
func.body.body[0].expression.type === 'CallExpression' &&
func.body.body[0].expression.callee.type === 'Identifier' &&
func.body.body[0].expression.callee.name === 'navigate'
) {
return true;
}
return false;
};
return {
VariableDeclarator: (node: any) => {
if (
node.init &&
node.init.type === 'ArrowFunctionExpression' &&
node.id.type === 'Identifier'
) {
const func = node.init;
functionMap[node.id.name] = func;
if (checkFunctionBodyHasSingleNavigateCall(func)) {
context.report({
node: func,
messageId: 'preferLink',
});
}
}
},
JSXAttribute: (node: any) => {
if (
node.name.name === 'onClick' &&
node.value.type === 'JSXExpressionContainer'
) {
const expression = node.value.expression;
if (
expression.type === 'ArrowFunctionExpression' &&
checkFunctionBodyHasSingleNavigateCall(expression)
) {
context.report({
node: expression,
messageId: 'preferLink',
});
} else if (
expression.type === 'Identifier' &&
functionMap[expression.name]
) {
const func = functionMap[expression.name];
if (checkFunctionBodyHasSingleNavigateCall(func)) {
context.report({
node: expression,
messageId: 'preferLink',
});
}
}
}
},
};
},
});

View File

@@ -0,0 +1,46 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './no-state-useref';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: 'const scrollableRef = useRef<HTMLDivElement>(null);',
filename: 'test.tsx',
},
{
code: 'const ref = useRef<HTMLInputElement>(null);',
filename: 'test.tsx',
},
],
invalid: [
{
code: 'const ref = useRef(null);',
errors: [
{
messageId: 'noStateUseRef',
},
],
},
{
code: 'const ref = useRef<Boolean>(null);',
errors: [
{
messageId: 'noStateUseRef',
},
],
filename: 'test.tsx',
},
{
code: "const ref = useRef<string>('');",
errors: [
{
messageId: 'noStateUseRef',
},
],
filename: 'test.tsx',
},
],
});

View File

@@ -0,0 +1,44 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'no-state-useref';
export const rule = defineRule({
meta: {
docs: {
description: "Don't use useRef for state management",
},
messages: {
test: 'test',
noStateUseRef:
"Don't use useRef for state management. See https://docs.twenty.com/developer/frontend/best-practices#do-not-use-useref-to-store-state for more details.",
},
type: 'suggestion',
schema: [],
},
create: (context) => {
return {
CallExpression: (node: any) => {
if (
node.callee?.type !== 'Identifier' ||
node.callee.name !== 'useRef'
)
return;
const typeParam = node.typeArguments?.params[0];
if (
!typeParam ||
typeParam.type !== 'TSTypeReference' ||
typeParam.typeName?.type !== 'Identifier' ||
!typeParam.typeName.name.match(/^(HTML.*Element|Element)$/)
) {
context.report({
node,
messageId: 'noStateUseRef',
});
return;
}
},
};
},
});

View File

@@ -0,0 +1,43 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './no-storybook-a11y-disable';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: "const meta = { parameters: { a11y: { test: 'error' } } };",
filename: 'Component.stories.tsx',
},
{
code: 'const meta = { parameters: { a11y: A11Y_DEFER_COLOR_CONTRAST } };',
filename: 'Component.stories.tsx',
},
{
code: "const config = { test: 'todo' };",
filename: 'Component.stories.tsx',
},
{
code: "const meta = { args: { a11y: { test: 'off' } } };",
filename: 'Component.stories.tsx',
},
],
invalid: [
{
code: "const meta = { parameters: { a11y: { test: 'todo' } } };",
errors: [{ messageId: 'noA11yDisable' }],
filename: 'Component.stories.tsx',
},
{
code: "const meta = { parameters: { a11y: { test: 'off' } } };",
errors: [{ messageId: 'noA11yDisable' }],
filename: 'Component.stories.tsx',
},
{
code: "export const Default = { parameters: { a11y: { test: 'off' } } };",
errors: [{ messageId: 'noA11yDisable' }],
filename: 'Component.stories.ts',
},
],
});

View File

@@ -0,0 +1,74 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'no-storybook-a11y-disable';
const DISABLING_TEST_VALUES = ['off', 'todo'];
const getPropertyKeyName = (node: any): string | undefined => {
if (node.type !== 'Property') {
return undefined;
}
if (node.key.type === 'Identifier') {
return node.key.name;
}
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
return node.key.value;
}
return undefined;
};
const isDisablingTestProperty = (node: any): boolean =>
node.type === 'Property' &&
getPropertyKeyName(node) === 'test' &&
node.value.type === 'Literal' &&
DISABLING_TEST_VALUES.includes(node.value.value);
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
"Disallow disabling the Storybook accessibility (axe) gate with a11y: { test: 'off' | 'todo' }.",
},
messages: {
noA11yDisable:
"Do not disable the accessibility gate with test: 'off' or test: 'todo'. Fix the axe violations, or defer only color-contrast via A11Y_DEFER_COLOR_CONTRAST.",
},
schema: [],
},
create: (context) => ({
Property: (node: any) => {
if (getPropertyKeyName(node) !== 'parameters') {
return;
}
if (node.value.type !== 'ObjectExpression') {
return;
}
const a11yProperty = node.value.properties.find(
(property: any) =>
getPropertyKeyName(property) === 'a11y' &&
property.value.type === 'ObjectExpression',
);
if (a11yProperty === undefined) {
return;
}
const disablingProperty = a11yProperty.value.properties.find(
isDisablingTestProperty,
);
if (disablingProperty !== undefined) {
context.report({
node: disablingProperty,
messageId: 'noA11yDisable',
});
}
},
}),
});

View File

@@ -0,0 +1,33 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import plugin from '../oxlint-plugin';
const RULES_DIRECTORY = join(import.meta.dirname, '.');
const getRuleModuleFileNames = () =>
readdirSync(RULES_DIRECTORY).filter(
(fileName) => fileName.endsWith('.ts') && !fileName.endsWith('.spec.ts'),
);
describe('oxlint plugin registry', () => {
it('registers every rule module', async () => {
const unregistered: string[] = [];
for (const fileName of getRuleModuleFileNames()) {
const module = await import(join(RULES_DIRECTORY, fileName));
if (module.RULE_NAME === undefined) {
continue;
}
if (plugin.rules[module.RULE_NAME] === undefined) {
unregistered.push(`${fileName} (${module.RULE_NAME})`);
}
}
expect(unregistered).toEqual([]);
});
});

View File

@@ -0,0 +1,110 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './prefer-workspace-scoped-repository';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
// Entity not on the blacklist — raw @InjectRepository is fine.
code: `
class WorkspaceService {
constructor(
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
}
`,
filename: 'workspace.service.ts',
},
{
// Blacklisted entity injected through the scoped wrapper.
code: `
class AgentTurnGraderService {
constructor(
@InjectWorkspaceScopedRepository(AgentTurnEntity)
private readonly turnRepository: WorkspaceScopedRepository<AgentTurnEntity>,
) {}
}
`,
filename: 'agent-turn-grader.service.ts',
},
{
// Non-constructor methods with decorators must be ignored.
code: `
class Service {
@SomeDecorator()
doStuff() {}
}
`,
filename: 'something.service.ts',
},
],
invalid: [
{
code: `
class AgentChatService {
constructor(
@InjectRepository(AgentChatThreadEntity)
private readonly threadRepository: Repository<AgentChatThreadEntity>,
) {}
}
`,
filename: 'agent-chat.service.ts',
errors: [
{
messageId: 'preferWorkspaceScopedRepository',
data: { entityName: 'AgentChatThreadEntity' },
},
],
},
{
// Multiple blacklisted entities → multiple errors.
code: `
class AgentChatService {
constructor(
@InjectRepository(AgentTurnEntity)
private readonly turnRepository: Repository<AgentTurnEntity>,
@InjectRepository(AgentMessageEntity)
private readonly messageRepository: Repository<AgentMessageEntity>,
) {}
}
`,
filename: 'agent-chat.service.ts',
errors: [
{
messageId: 'preferWorkspaceScopedRepository',
data: { entityName: 'AgentTurnEntity' },
},
{
messageId: 'preferWorkspaceScopedRepository',
data: { entityName: 'AgentMessageEntity' },
},
],
},
{
// Plain (non-parameter-property) constructor parameter with an
// explicit assignment in the body. Must still be caught — the
// rule should not depend on the TSParameterProperty shorthand.
code: `
class AgentChatService {
private readonly threadRepository: Repository<AgentChatThreadEntity>;
constructor(
@InjectRepository(AgentChatThreadEntity)
threadRepository: Repository<AgentChatThreadEntity>,
) {
this.threadRepository = threadRepository;
}
}
`,
filename: 'agent-chat.service.ts',
errors: [
{
messageId: 'preferWorkspaceScopedRepository',
data: { entityName: 'AgentChatThreadEntity' },
},
],
},
],
});

View File

@@ -0,0 +1,135 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'prefer-workspace-scoped-repository';
// Entities that do not fit the scoped wrapper: workspace itself, pivots,
// nullable-workspaceId rows (instance-level config / migrations / tokens),
// and global tables with no workspaceId column at all.
const STRUCTURAL_EXEMPTIONS = new Set<string>([
'WorkspaceEntity',
'UserWorkspaceEntity',
'AppTokenEntity',
'ApplicationRegistrationEntity',
'ApplicationRegistrationClaimEntity',
'ApplicationRegistrationVariableEntity',
// nullable workspaceId — both rows support instance-level and per-workspace use
'KeyValuePairEntity',
'UpgradeMigrationEntity',
// user-scoped auth sessions; workspaceId is null for workspace-agnostic sessions
'UserSessionEntity',
'ApplicationVariableEntity',
'BillingMeterEntity',
'BillingPriceEntity',
'BillingProductEntity',
'BillingSubscriptionItemEntity',
'ConnectedAccountEntity',
'ConnectionProviderEntity',
'FrontComponentEntity',
'LogicFunctionEntity',
'MessageFolderEntity',
'RolePermissionFlagEntity',
'SigningKeyEntity',
'UserEntity',
'WorkspaceSsoIdentityProviderEntity',
]);
// Workspace-scoped entities the wrapper could technically wrap, but where
// the dominant access patterns are cross-workspace (request routing, auth,
// metadata sync, file storage, transaction-bound channel updates) and the
// payoff doesn't justify dual-injecting every call site or growing the
// wrapper API. Treat as a "deliberately not migrated" list, not a backlog.
const WORKSPACE_SCOPED_EXEMPTIONS = new Set<string>([
// Resolved by id alone at auth/request-routing time and inside file-storage
// transactions; very few of the ~50 call sites carry a workspaceId.
'ApplicationEntity',
// Read by user across every workspace they belong to (the "apps you
// authorized" screen) and from the OAuth token endpoint, which has no
// request workspace to scope by.
'ApplicationAuthorizationEntity',
// 20+ call sites across calendar/messaging modules; staged for a dedicated PR.
'CalendarChannelEntity',
'MessageChannelEntity',
// The owning services `extends TypeOrmQueryService<E>` and pass the raw
// repo to `super(...)`; the superclass type doesn't accept the wrapper.
'FieldMetadataEntity',
'ObjectMetadataEntity',
// Only injection lives in a frozen historical upgrade-version-command
// directory that CI's mutation-guard refuses to let us edit.
'DataSourceEntity',
// The domain column is globally unique across workspaces, so duplicate
// preflight checks must query cross-workspace; writes stay on the wrapper.
'EmailingDomainEntity',
]);
// Everything else must use @InjectWorkspaceScopedRepository.
const EXCLUSIONS = new Set<string>([
...STRUCTURAL_EXEMPTIONS,
...WORKSPACE_SCOPED_EXEMPTIONS,
]);
const matchInjectRepositoryEntity = (decorator: any): string | null => {
if (decorator.expression?.type !== 'CallExpression') {
return null;
}
const callee = decorator.expression.callee;
if (callee?.type !== 'Identifier' || callee.name !== 'InjectRepository') {
return null;
}
const [arg] = decorator.expression.arguments;
if (arg?.type !== 'Identifier') {
return null;
}
if (!arg.name.endsWith('Entity')) {
return null;
}
if (EXCLUSIONS.has(arg.name)) {
return null;
}
return arg.name;
};
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Disallow raw @InjectRepository for workspace-scoped entities. Use @InjectWorkspaceScopedRepository so workspaceId is enforced on every read/write.',
},
schema: [],
messages: {
preferWorkspaceScopedRepository:
'Use @InjectWorkspaceScopedRepository({{entityName}}) instead of raw @InjectRepository so the workspaceId guard is enforced. If {{entityName}} genuinely does not fit, add it to EXCLUSIONS or suppress with `// eslint-disable-next-line twenty/prefer-workspace-scoped-repository` and a short reason.',
},
},
create: (context) => {
return {
MethodDefinition: (node: any) => {
if (node.kind !== 'constructor') {
return;
}
for (const param of node.value.params ?? []) {
for (const decorator of param.decorators ?? []) {
const entityName = matchInjectRepositoryEntity(decorator);
if (entityName !== null) {
context.report({
node: decorator,
messageId: 'preferWorkspaceScopedRepository',
data: { entityName },
});
}
}
}
},
};
},
});

View File

@@ -0,0 +1,327 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './rest-api-methods-should-be-guarded';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: `
class TestController {
@Get()
@UseGuards(UserAuthGuard, NoPermissionGuard)
testMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestController {
@Get()
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
testMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestController {
@Get()
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
testMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestController {
@Get()
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
testMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
@UseGuards(UserAuthGuard, NoPermissionGuard)
class TestController {
@Get()
testMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
class TestController {
@Get()
testMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
class TestController {
@Get()
testMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestController {
@Post()
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
createMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestController {
@Put()
@UseGuards(WorkspaceAuthGuard, UpdatePermissionGuard)
updateMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestController {
@Patch()
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
patchMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestController {
@Delete()
@UseGuards(WorkspaceAuthGuard, DeletePermissionGuard)
deleteMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
class TestController {
@Post()
createMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.WORKSPACE))
class TestController {
@Delete()
deleteMethod() {}
}
`,
filename: 'test.tsx',
},
{
code: `
class TestController {
regularMethod() {}
}
`,
},
],
invalid: [
{
code: `
class TestController {
@Get()
testMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestController {
@Post()
testMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestController {
@Get()
@UseGuards(WorkspaceAuthGuard)
testMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestController {
@Get()
@UseGuards(CaptchaGuard)
testMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
@UseGuards(CaptchaGuard)
class TestController {
@Get()
testMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard)
class TestController {
@Get()
testMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestController {
@Post()
@UseGuards(WorkspaceAuthGuard)
createMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestController {
@Put()
@UseGuards(WorkspaceAuthGuard)
updateMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestController {
@Patch()
@UseGuards(WorkspaceAuthGuard)
patchMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
class TestController {
@Delete()
@UseGuards(WorkspaceAuthGuard)
deleteMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard)
class TestController {
@Post()
createMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
{
code: `
@UseGuards(WorkspaceAuthGuard)
class TestController {
@Delete()
deleteMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
filename: 'test.tsx',
},
],
});

View File

@@ -0,0 +1,21 @@
import { createGuardedEndpointRule } from '../utils/createGuardedEndpointRule';
export const RULE_NAME = 'rest-api-methods-should-be-guarded';
export const rule = createGuardedEndpointRule({
triggerDecorators: [
'Get',
'Post',
'Put',
'Delete',
'Patch',
'Options',
'Head',
'All',
],
messageId: 'restApiMethodsShouldBeGuarded',
description:
'REST API endpoints should have authentication guards (UserAuthGuard, WorkspaceAuthGuard, FilePathGuard, FileByIdGuard, FileUploadTokenGuard) or be explicitly marked as public (PublicEndpointGuard) and permission guards (SettingsPermissionGuard or CustomPermissionGuard) to maintain our security model.',
message:
'All REST API controller endpoints must have authentication guards (@UseGuards(...)) and permission guards (@UseGuards(..., SettingsPermissionGuard(...)), CustomPermissionGuard, or NoPermissionGuard).',
});

View File

@@ -0,0 +1,43 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './sort-css-properties-alphabetically';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: 'const style = css`color: red;`;',
},
{
code: 'const style = css`background-color: $bgColor;color: red;`;',
},
{
code: 'const StyledComponent = styled.div`color: red;`;',
},
{
code: 'const StyledComponent = styled.div`background-color: $bgColor;color: red;`;',
},
],
invalid: [
{
code: 'const style = css`color: #FF0000;background-color: $bgColor`;',
output: 'const style = css`background-color: $bgColorcolor: #FF0000;`;',
errors: [
{
messageId: 'sortCssPropertiesAlphabetically',
},
],
},
{
code: 'const StyledComponent = styled.div`color: #FF0000;background-color: $bgColor`;',
output:
'const StyledComponent = styled.div`background-color: $bgColorcolor: #FF0000;`;',
errors: [
{
messageId: 'sortCssPropertiesAlphabetically',
},
],
},
],
});

View File

@@ -0,0 +1,270 @@
import postcss from 'postcss';
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'sort-css-properties-alphabetically';
interface Loc {
start: {
line: number;
column: number;
};
end: {
line: number;
column: number;
};
}
const isMemberExpression = (node: any): boolean =>
node.type === 'MemberExpression';
const isCallExpression = (node: any): boolean =>
node.type === 'CallExpression';
const isStyledTagname = (node: any): boolean => {
if (node.tag?.type === 'Identifier') {
return node.tag.name === 'css';
}
if (
isMemberExpression(node.tag) &&
node.tag.object?.type === 'Identifier'
) {
return node.tag.object.name === 'styled';
}
if (
isCallExpression(node.tag) &&
node.tag.callee?.type === 'Identifier'
) {
return node.tag.callee.name === 'styled';
}
if (
isCallExpression(node.tag) &&
isMemberExpression(node.tag.callee) &&
node.tag.callee.object?.type === 'Identifier'
) {
return node.tag.callee.object.name === 'styled';
}
if (
isCallExpression(node.tag) &&
isMemberExpression(node.tag.callee) &&
isMemberExpression(node.tag.callee.object) &&
node.tag.callee.object.object?.type === 'Identifier'
) {
return node.tag.callee.object.object.name === 'styled';
}
return false;
};
const isValidAtomicRule = (
rule: postcss.Rule,
): { isValid: boolean; loc?: Loc } => {
const decls = rule.nodes.filter(
(node) => node.type === 'decl',
) as unknown as postcss.Declaration[];
const invalidDeclIndex = decls.findIndex((decl, index) => {
if (index === 0) return false;
const current = decl.prop;
const prev = decls[index - 1].prop;
return current < prev;
});
return invalidDeclIndex > 0
? {
isValid: false,
loc: {
start: {
line: decls[invalidDeclIndex - 1].source!.start!.line,
column:
decls[invalidDeclIndex - 1].source!.start!.column - 1,
},
end: {
line: decls[invalidDeclIndex].source!.end!.line,
column: decls[invalidDeclIndex].source!.end!.column - 1,
},
},
}
: { isValid: true };
};
const isValidRule = (
rule: postcss.Rule,
): { isValid: boolean; loc?: Loc } => {
const { isValid, loc } = rule.nodes.reduce<{
isValid: boolean;
loc?: Loc;
}>(
(map, node) => {
return node.type === 'rule' ? isValidRule(node) : map;
},
{ isValid: true },
);
if (!isValid) {
return { isValid, loc };
}
return isValidAtomicRule(rule);
};
const getNodeStyles = (node: any): string => {
const [firstQuasi, ...quasis] = node.quasi.quasis;
const lineBreakCount = node.quasi.loc.start.line - 1;
let styles = `${'\n'.repeat(lineBreakCount)}${' '.repeat(
node.quasi.loc.start.column + 1,
)}${firstQuasi.value.raw}`;
quasis.forEach(({ value, loc }: any, idx: number) => {
const prevLoc = idx === 0 ? firstQuasi.loc : quasis[idx - 1].loc;
const lineBreaksCount = loc.start.line - prevLoc.end.line;
const spacesCount =
loc.start.line === prevLoc.end.line
? loc.start.column - prevLoc.end.column + 2
: loc.start.column + 1;
styles = `${styles}${' '}${'\n'.repeat(lineBreaksCount)}${' '.repeat(
spacesCount,
)}${value.raw}`;
});
return styles;
};
const fix = ({
rule,
fixer,
src,
}: {
rule: postcss.Rule;
fixer: any;
src: any;
}): any[] => {
const fixings = rule.nodes
.filter((node): node is postcss.Rule => node.type === 'rule')
.flatMap((node) => fix({ rule: node, fixer, src }));
const declarations = rule.nodes.filter(
(node): node is postcss.Declaration => node.type === 'decl',
);
const sortedDeclarations = sortDeclarations(declarations);
return [
...fixings,
...declarations.flatMap((decl, index) => {
if (!areSameDeclarations(decl, sortedDeclarations[index])) {
try {
const range = getDeclRange({ decl, src });
const sortedDeclText = getDeclText({
decl: sortedDeclarations[index],
src,
});
return [
fixer.removeRange([range.startIdx, range.endIdx + 1]),
fixer.insertTextAfterRange(
[range.startIdx, range.startIdx],
sortedDeclText,
),
];
} catch (e) {
console.log(e);
}
}
}),
];
};
const areSameDeclarations = (
a: postcss.ChildNode,
b: postcss.ChildNode,
): boolean =>
a.source!.start!.line === b.source!.start!.line &&
a.source!.start!.column === b.source!.start!.column;
const getDeclRange = ({
decl,
src,
}: {
decl: postcss.ChildNode;
src: any;
}): { startIdx: number; endIdx: number } => {
const loc = {
start: {
line: decl.source!.start!.line,
column: decl.source!.start!.column - 1,
},
end: {
line: decl.source!.end!.line,
column: decl.source!.end!.column - 1,
},
};
const startIdx = src.getIndexFromLoc(loc.start);
const endIdx = src.getIndexFromLoc(loc.end);
return { startIdx, endIdx };
};
const getDeclText = ({
decl,
src,
}: {
decl: postcss.ChildNode;
src: any;
}) => {
const { startIdx, endIdx } = getDeclRange({ decl, src });
return src.getText().substring(startIdx, endIdx + 1);
};
const sortDeclarations = (declarations: postcss.Declaration[]) =>
declarations
.slice()
.sort((declA, declB) => (declA.prop > declB.prop ? 1 : -1));
export const rule = defineRule({
meta: {
docs: {
description: 'Styles are sorted alphabetically.',
},
messages: {
sortCssPropertiesAlphabetically:
'Declarations should be sorted alphabetically.',
},
type: 'suggestion',
schema: [],
fixable: 'code',
},
create: (context) => {
return {
TaggedTemplateExpression: (node: any) => {
if (!isStyledTagname(node)) return;
try {
const root = postcss.parse(
getNodeStyles(node),
) as unknown as postcss.Rule;
const { isValid } = isValidRule(root);
if (!isValid) {
return context.report({
node,
messageId: 'sortCssPropertiesAlphabetically',
fix: (fixer) =>
fix({
rule: root,
fixer,
src: context.sourceCode,
}),
});
}
} catch (e) {
return true;
}
},
};
},
});

View File

@@ -0,0 +1,34 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './styled-components-prefixed-with-styled';
const ruleTester = new RuleTester();
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: 'const StyledButton = styled.button``;',
},
{
code: 'const StyledComponent = styled.div``;',
},
],
invalid: [
{
code: 'const Button = styled.button``;',
errors: [
{
messageId: 'noStyledPrefix',
},
],
},
{
code: 'const Component = styled.div``;',
errors: [
{
messageId: 'noStyledPrefix',
},
],
},
],
});

View File

@@ -0,0 +1,48 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'styled-components-prefixed-with-styled';
export const rule = defineRule({
meta: {
type: 'suggestion',
docs: {
description:
'Warn when StyledComponents are not prefixed with Styled',
},
messages: {
noStyledPrefix:
'{{componentName}} is a StyledComponent and is not prefixed with Styled.',
},
fixable: 'code',
schema: [],
},
create: (context) => {
const isStyledRoot = (node: any): boolean => {
if (node?.type === 'Identifier' && node.name === 'styled') return true;
if (node?.type === 'MemberExpression') return isStyledRoot(node.object);
if (node?.type === 'CallExpression') return isStyledRoot(node.callee);
return false;
};
return {
VariableDeclarator: (node: any) => {
const templateExpr = node.init;
if (templateExpr?.type !== 'TaggedTemplateExpression') return;
if (
node.id?.type === 'Identifier' &&
isStyledRoot(templateExpr.tag)
) {
const variable = node.id;
if (variable.name.startsWith('Styled')) return;
context.report({
node,
messageId: 'noStyledPrefix',
data: { componentName: variable.name },
});
}
},
};
},
});

View File

@@ -0,0 +1,94 @@
import { RuleTester } from 'oxlint/plugins-dev';
import { rule, RULE_NAME } from './upgrade-command-filename';
const ruleTester = new RuleTester();
const DUMMY_CODE = 'const x = 1;';
const BASE =
'/project/packages/twenty-server/src/database/commands/upgrade-version-command';
const filename = (path: string) => `${BASE}/${path}`;
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: DUMMY_CODE,
filename: filename(
'1-21/1-21-workspace-command-1775500001000-add-compose-email.command.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'1-21/1-21-workspace-command-1775500011000-migrate-message-folder-parent-id-to-external-id.command.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'1-21/1-21-instance-command-fast-1775500000000-add-foo-column.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'1-22/1-22-instance-command-fast-1780000000000-create-task-table.ts',
),
},
{
code: DUMMY_CODE,
filename: filename(
'1-22/1-22-instance-command-slow-1775758621018-backfill-workspace-id.ts',
),
},
{
code: DUMMY_CODE,
filename: filename('1-21/1-21-upgrade-version-command.module.ts'),
},
{
code: DUMMY_CODE,
filename: filename(
'1-21/__tests__/1-21-workspace-command.spec.ts',
),
},
{
code: DUMMY_CODE,
filename: '/project/packages/twenty-front/src/components/Button.tsx',
},
{
code: DUMMY_CODE,
filename: filename('upgrade.command.ts'),
},
],
invalid: [
{
code: DUMMY_CODE,
filename: filename(
'1-21/1-21-workspace-command-add-feature.command.ts',
),
errors: [{ messageId: 'invalidWorkspaceCommandFilename' }],
},
{
code: DUMMY_CODE,
filename: filename(
'1-21/1-21-workspace-command-12345-short-timestamp.command.ts',
),
errors: [{ messageId: 'invalidWorkspaceCommandFilename' }],
},
{
code: DUMMY_CODE,
filename: filename(
'1-21/1-21-instance-command-fast-add-column.ts',
),
errors: [{ messageId: 'invalidInstanceCommandFastFilename' }],
},
{
code: DUMMY_CODE,
filename: filename(
'1-21/1-21-add-compose-email-command-menu-item.command.ts',
),
errors: [{ messageId: 'invalidUpgradeCommandFilename' }],
},
],
});

View File

@@ -0,0 +1,117 @@
import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'upgrade-command-filename';
const WORKSPACE_COMMAND_REGEX =
/^\d+-\d+-workspace-command-\d{13,}-[a-z0-9]+(?:-[a-z0-9]+)*\.command\.ts$/;
const INSTANCE_COMMAND_FAST_REGEX =
/^\d+-\d+-instance-command-fast-\d{13,}-[a-z0-9]+(?:-[a-z0-9]+)*\.ts$/;
const INSTANCE_COMMAND_SLOW_REGEX =
/^\d+-\d+-instance-command-slow-\d{13,}-[a-z0-9]+(?:-[a-z0-9]+)*\.ts$/;
const SKIPPED_FILE_REGEX =
/\.(module|spec|test|snap)\.ts$|__tests__|__mocks__|__snapshots__/;
const COMMAND_PREFIX_REGEX = /^\d+-\d+-/;
const extractUpgradeCommandRelativePath = (
filename: string,
): string | null => {
const marker = 'upgrade-version-command/';
const index = filename.indexOf(marker);
if (index === -1) {
return null;
}
return filename.slice(index + marker.length);
};
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Enforce consistent filename patterns for workspace and instance upgrade commands',
},
schema: [],
messages: {
invalidWorkspaceCommandFilename:
"Workspace command filename '{{ name }}' must match pattern: {major}-{minor}-workspace-command-{timestamp}-{description}.command.ts (e.g. '1-21-workspace-command-1775500001000-add-feature.command.ts')",
invalidInstanceCommandFastFilename:
"Instance command filename '{{ name }}' must match pattern: {major}-{minor}-instance-command-fast-{timestamp}-{description}.ts (e.g. '1-21-instance-command-fast-1775500001000-add-column.ts')",
invalidInstanceCommandSlowFilename:
"Instance command filename '{{ name }}' must match pattern: {major}-{minor}-instance-command-slow-{timestamp}-{description}.ts (e.g. '1-22-instance-command-slow-1775500001000-backfill-data.ts')",
invalidUpgradeCommandFilename:
"Upgrade command filename '{{ name }}' does not match any recognized pattern. Expected workspace-command, instance-command-fast, or instance-command-slow format.",
},
},
create: (context) => {
return {
Program: (node: any) => {
const relativePath = extractUpgradeCommandRelativePath(
context.filename,
);
if (!relativePath) {
return;
}
const segments = relativePath.split('/');
const basename = segments[segments.length - 1];
if (SKIPPED_FILE_REGEX.test(basename)) {
return;
}
if (!COMMAND_PREFIX_REGEX.test(basename)) {
return;
}
if (basename.includes('workspace-command-')) {
if (!WORKSPACE_COMMAND_REGEX.test(basename)) {
context.report({
node,
messageId: 'invalidWorkspaceCommandFilename',
data: { name: basename },
});
}
return;
}
if (basename.includes('instance-command-fast-')) {
if (!INSTANCE_COMMAND_FAST_REGEX.test(basename)) {
context.report({
node,
messageId: 'invalidInstanceCommandFastFilename',
data: { name: basename },
});
}
return;
}
if (basename.includes('instance-command-slow-')) {
if (!INSTANCE_COMMAND_SLOW_REGEX.test(basename)) {
context.report({
node,
messageId: 'invalidInstanceCommandSlowFilename',
data: { name: basename },
});
}
return;
}
context.report({
node,
messageId: 'invalidUpgradeCommandFilename',
data: { name: basename },
});
},
};
},
});