diff --git a/eslint.config.mjs b/eslint.config.mjs index 85f5020b..333e96bb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -81,6 +81,7 @@ export default eslintTs.config( 'import-x/no-named-as-default-member': 'off', 'import-x/no-named-as-default': 'off', 'import-x/default': 'off', // fix https://github.com/import-js/eslint-plugin-import/issues/1800 + 'import-x/namespace': 'off', // very slow, see https://github.com/import-js/eslint-plugin-import/issues/2340 'import-x/order': 'warn', }, settings: { diff --git a/package.json b/package.json index 22fda070..a78341b4 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "eslint": "^9.10.0", "eslint-config-prettier": "9.1.0", "eslint-import-resolver-typescript": "3.6.3", - "eslint-plugin-artalk": "^1.0.1", + "eslint-plugin-artalk": "^1.0.2", "eslint-plugin-compat": "^6.0.1", "eslint-plugin-import-x": "^4.2.1", "eslint-plugin-react": "^7.36.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f608f815..2b1b80de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,8 +45,8 @@ importers: specifier: 3.6.3 version: 3.6.3(@typescript-eslint/parser@8.6.0(eslint@9.10.0)(typescript@5.6.2))(eslint-plugin-import-x@4.2.1(eslint@9.10.0)(typescript@5.6.2))(eslint-plugin-import@2.30.0)(eslint@9.10.0) eslint-plugin-artalk: - specifier: ^1.0.1 - version: 1.0.1(eslint@9.10.0)(typescript@5.6.2) + specifier: ^1.0.2 + version: 1.0.2(eslint@9.10.0)(typescript@5.6.2) eslint-plugin-compat: specifier: ^6.0.1 version: 6.0.1(eslint@9.10.0) @@ -2471,8 +2471,8 @@ packages: eslint-import-resolver-webpack: optional: true - eslint-plugin-artalk@1.0.1: - resolution: {integrity: sha512-mEgJj6kqfgDqHHmHVH6O06s3bhINyBb6TItYkbsASsO3+2WCB8CGHqFxz7vqoyymFH5n6x1GwevfPWlbOg3Kgw==} + eslint-plugin-artalk@1.0.2: + resolution: {integrity: sha512-NXWYEHzFMRDnykHsJWs0nWvYXINCqRZPnq5VbbMVmNbAT5rh/h4YfOeUUjCNW61y9bmuYbGvumsTsWw/U99DCQ==} peerDependencies: eslint: '>=9' @@ -7275,7 +7275,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-artalk@1.0.1(eslint@9.10.0)(typescript@5.6.2): + eslint-plugin-artalk@1.0.2(eslint@9.10.0)(typescript@5.6.2): dependencies: '@typescript-eslint/utils': 8.8.0(eslint@9.10.0)(typescript@5.6.2) eslint: 9.10.0 diff --git a/ui/eslint-plugin-artalk/README.md b/ui/eslint-plugin-artalk/README.md index 5dabf937..dcd7794d 100644 --- a/ui/eslint-plugin-artalk/README.md +++ b/ui/eslint-plugin-artalk/README.md @@ -70,11 +70,69 @@ The ESLint rule `artalk/artalk-plugin` enforces the conventions for Artalk plugi The ESLint rule is only enabled when a TypeScript file imports the `ArtalkPlugin` type from the `artalk` package and defines an arrow function variable with the type `ArtalkPlugin`, such as `const TestPlugin: ArtalkPlugin = (ctx) => {}`. The variable type must be `ArtalkPlugin`. -#### `noLifeCycleEventInNestedBlocks` +#### `noCycleDeps` -Should not allow life-cycle event listeners to be defined inside nested blocks. +Circular dependencies should not be allowed in the `provide` method. The method must not inject a dependency that it also provides, including indirect circular references (e.g., `a` -> `b` -> `c` -> `a`). -The life-cycle event listeners are `created`, `mounted`, `updated`, and `destroyed` must be defined in the top-level scope of the ArtalkPlugin arrow function. +The best way to deal with this situation is to do some kind of refactor to avoid the cyclic dependencies. + +**⚠️ Fail**: + +```ts +import type { ArtalkPlugin } from 'artalk' + +export const TestPlugin: ArtalkPlugin = (ctx) => { + ctx.provide('foo', (foo) => {}, ['foo']) +} +``` + +```ts +import type { ArtalkPlugin } from 'artalk' + +// foo.ts +const FooPlugin: ArtalkPlugin = (ctx) => { + ctx.provide('foo', (bar) => {}, ['bar']) +} + +// bar.ts +const BarPlugin: ArtalkPlugin = (ctx) => { + ctx.provide('bar', (foo) => {}, ['foo']) +} +``` + +**✅ Pass**: + +You can introduce a mediator to resolve circular dependencies. The mediator will handle interactions between the dependencies, breaking the direct circular relationship while maintaining their communication through the mediator. + +```ts +import type { ArtalkPlugin } from 'artalk' + +// foo.ts +const FooPlugin: ArtalkPlugin = (ctx) => { + ctx.provide('foo', () => {}) +} + +// bar.ts +const BarPlugin: ArtalkPlugin = (ctx) => { + ctx.provide('bar', () => {}) +} + +// mediator.ts +const MediatorPlugin: ArtalkPlugin = (ctx) => { + ctx.provide( + 'mediator', + (foo, bar) => { + // ... + // interact with foo and bar + }, + ['foo', 'bar'], + ) +} +``` + +#### `noLifeCycleEventInNestedBlocks` + +Life-cycle event listeners such as `created`, `mounted`, `updated`, and `destroyed` should not be defined inside nested blocks. They must be placed in the top-level scope of the `ArtalkPlugin` arrow function to ensure clarity and maintainability. **⚠️ Fail**: @@ -102,7 +160,7 @@ export const TestPlugin: ArtalkPlugin = (ctx) => { #### `noEventInWatchConf` -Should not allow event listeners to be defined inside watchConf effect function. +Event listeners should not be defined inside the `watchConf` effect function. They must be placed outside to ensure proper separation of concerns and to avoid unintended side effects. **⚠️ Fail**: @@ -116,6 +174,97 @@ export const TestPlugin: ArtalkPlugin = (ctx) => { } ``` +**✅ Pass**: + +```ts +import type { ArtalkPlugin } from 'artalk' + +export const TestPlugin: ArtalkPlugin = (ctx) => { + ctx.on('update', () => {}) + + ctx.watchConf(['el'], (conf) => {}) +} +``` + +#### `noInjectInNestedBlocks` + +The `inject` method should not be called inside nested blocks. It must be used at the top-level scope of the `ArtalkPlugin` arrow function. For better readability and maintainability, it is recommended to place the `inject` call at the beginning of the function. + +**⚠️ Fail**: + +```ts +import type { ArtalkPlugin } from 'artalk' + +export const TestPlugin: ArtalkPlugin = (ctx) => { + const fn = () => { + const foo = ctx.inject('foo') + } +} +``` + +**✅ Pass**: + +```ts +import type { ArtalkPlugin } from 'artalk' + +export const TestPlugin: ArtalkPlugin = (ctx) => { + const foo = ctx.inject('foo') +} +``` + +#### `noInjectOutsidePlugin` + +The `inject` method should not be called outside the `ArtalkPlugin` arrow function. It must be used in the top-level scope of the `ArtalkPlugin` function to ensure the dependency injection remains readable and maintainable. + +**⚠️ Fail**: + +```ts +function fn(ctx) { + const foo = ctx.inject('foo') +} +``` + +**✅ Pass**: + +```ts +import type { ArtalkPlugin } from 'artalk' + +export const TestPlugin: ArtalkPlugin = (ctx) => { + const foo = ctx.inject('foo') +} +``` + +#### `onePluginPerFile` + +Multiple plugins should not be defined in the same file. Each plugin must be defined in its own separate file to improve code organization and maintainability. + +**⚠️ Fail**: + +```ts +import type { ArtalkPlugin } from 'artalk' + +export const TestPlugin: ArtalkPlugin = (ctx) => {} +export const AnotherPlugin: ArtalkPlugin = (ctx) => {} +``` + +**✅ Pass**: + +TestPlugin.ts: + +```ts +import type { ArtalkPlugin } from 'artalk' + +export const TestPlugin: ArtalkPlugin = (ctx) => {} +``` + +AnotherPlugin.ts: + +```ts +import type { ArtalkPlugin } from 'artalk' + +export const AnotherPlugin: ArtalkPlugin = (ctx) => {} +``` + ## License [MIT](https://github.com/ArtalkJS/Artalk/blob/master/LICENSE) diff --git a/ui/eslint-plugin-artalk/package.json b/ui/eslint-plugin-artalk/package.json index ac7c0696..1fdda881 100644 --- a/ui/eslint-plugin-artalk/package.json +++ b/ui/eslint-plugin-artalk/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-artalk", - "version": "1.0.1", + "version": "1.0.2", "type": "module", "license": "MIT", "homepage": "https://github.com/ArtalkJS/Artalk/tree/master/ui/eslint-plugin-artalk", diff --git a/ui/eslint-plugin-artalk/src/artalk-plugin-checkers.ts b/ui/eslint-plugin-artalk/src/artalk-plugin-checkers.ts new file mode 100644 index 00000000..c9b66977 --- /dev/null +++ b/ui/eslint-plugin-artalk/src/artalk-plugin-checkers.ts @@ -0,0 +1,372 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils' +import type { ArtalkPluginCheckerContext, DepsData, DepsStore } from './artalk-plugin' +import { tarjan } from './scc' + +/** The event function names in Context */ +const ctxEventFns = ['off', 'on', 'trigger'] + +/** The life-cycle event names in Context */ +const ctxLifeCycleEvents = ['mounted', 'destroyed', 'updated', 'list-fetched'] + +/** Whether the given string is a ArtalkPlugin name */ +export function isPluginName(s: string) { + return s === 'ArtalkPlugin' || /Artalk[A-Z0-9].*Plugin/.test(s) +} + +/** + * Get the references to Context in the top scope of the given scope + */ +const getCtxRefNamesInTopScope = (ctxArgName: string, scope: TSESLint.Scope.Scope) => { + const ctxRefs = new Map() + + const getFullMethodName = (node: TSESTree.Node) => { + const methodNameArr: string[] = [] + let curr: TSESTree.Node | undefined = node + const visited = new Set() + while (curr) { + if (visited.has(curr)) break + visited.add(curr) + if (curr.type === 'MemberExpression' && curr.property.type === 'Identifier') + methodNameArr.push(curr.property.name) + curr = curr.parent + } + return methodNameArr.join('.') + } + + scope.references.forEach((reference) => { + const identifier = reference.identifier + if (identifier.name !== ctxArgName) return + + const methodName = getFullMethodName(identifier.parent) + if (methodName) ctxRefs.set(identifier.parent, methodName) + }) + + return ctxRefs +} + +/** + * Get the references to Context in the nested scopes of the given + */ +const getCtxRefNamesInNestedScope = ( + ctxArgName: string, + parentScope: TSESLint.Scope.Scope, + keepTop = true, +) => { + const ctxRefs = new Map() + keepTop && getCtxRefNamesInTopScope(ctxArgName, parentScope).forEach((v, k) => ctxRefs.set(k, v)) + parentScope.childScopes.forEach((childScope) => { + getCtxRefNamesInNestedScope(ctxArgName, childScope).forEach((v, k) => ctxRefs.set(k, v)) + }) + return ctxRefs +} + +/** + * Check the set of all function names in Context + * + * (which is called in the top-level of ArtalkPlugin arrow-function scope) + */ +const checkTopLevelCtxRefs = (ctx: ArtalkPluginCheckerContext, m: Map) => { + // console.debug('checkTopLevelCtxFnCalls', m.values()) + // ... +} + +const getDepsMap = (node: TSESTree.CallExpression): DepsData | void => { + if (node.arguments.length < 3) return + + // Get the dependencies + let depsArg = node.arguments[2] + if (depsArg.type === 'TSAsExpression') depsArg = depsArg.expression + if (depsArg.type !== 'ArrayExpression') return + const deps = depsArg.elements + .map((e) => (e && e.type === 'Literal' && typeof e.value === 'string' ? e.value : '')) + .filter((e) => !!e) + if (deps.length === 0) return + + // Get the provider name + const providerNameArg = node.arguments[0] + if (providerNameArg.type !== 'Literal') return + const providerName = providerNameArg.value + if (!providerName || typeof providerName !== 'string') return + + // Record the dependency data for the file + const depsMap: DepsData = new Map() + deps.forEach((depName) => { + if (!depsMap.has(depName)) depsMap.set(depName, new Set()) + depsMap.get(depName)!.add(providerName) + }) + + return depsMap +} + +function removePath(graph: DepsData, path: string[]): DepsData { + if (path.length < 2) { + throw new Error('Path must have at least two nodes') + } + + // Clone the data + const updatedGraph = new Map(graph) + updatedGraph.forEach((value, key) => { + updatedGraph.set(key, new Set(value)) + }) + + for (let i = 0; i < path.length - 1; i++) { + const toNode = path[i] + const fromNode = path[i + 1] + + // Remove the edge from fromNode to toNode + if (updatedGraph.has(fromNode)) { + const edges = updatedGraph.get(fromNode) + if (edges) { + // Remove the edge to toNode from the adjacency set of fromNode + edges.delete(toNode) + if (edges.size === 0) { + // If the node no longer has any edges, remove the node + updatedGraph.delete(fromNode) + } + } + } + } + + return updatedGraph +} + +const checkCircularDependency = ( + depsStore: DepsStore, + ctx: ArtalkPluginCheckerContext, + _node: TSESTree.CallExpression, +) => { + const args = _node.arguments + const node = args.length >= 3 ? args[2] : _node + + // Merge all files' dependency data + const depsGraph: DepsData = new Map() + depsStore.forEach((depsData, filename) => { + depsData.forEach((providers, depName) => { + if (!depsGraph.has(depName)) depsGraph.set(depName, new Set()) + providers.forEach((provider) => depsGraph.get(depName)!.add(provider)) + }) + }) + + // console.log('\n' + ctx.eslint.filename) + // console.log('depsGraph', depsGraph) + // console.log('tarjan', tarjan(depsGraph)) + + // Basic check (self-reference, a->a) + for (const [depName, providers] of depsGraph) { + if (providers.has(depName)) { + ctx.eslint.report({ + node, + messageId: 'noCycleDeps', + data: { route: `${depName}->${depName}` }, + }) + return + } + } + + // SCC (Strongly Connected Components) algorithm + tarjan(depsGraph).forEach((scc) => { + if (scc.size <= 1) return + const route = [...scc, scc.values().next().value].slice(1).join('->') + + ctx.eslint.report({ + node, + messageId: 'noCycleDeps', + data: { route }, + }) + + // Cleanup + depsStore.forEach((depsData, filename) => { + depsStore.set(filename, removePath(depsData, [...scc])) + }) + }) +} + +/** + * Check the set of all function names in Context + * + * (which is called in the nested scopes of ArtalkPlugin arrow-function scope) + */ +const checkNestedCtxRefs = (ctx: ArtalkPluginCheckerContext, m: Map) => { + // console.debug('checkAllCtxFnCalls', m.values()) + // ... + // TODO: Event Circular trigger Check + + const depsMap: DepsData = new Map() + + m.forEach((methodName, node) => { + // Check dependency providers via `ctx.provide` + if (methodName === 'provide') { + const callExpr = node.parent + if (!callExpr || callExpr.type !== 'CallExpression') return + + // Record + const dm = getDepsMap(callExpr) + if (!dm) return + + // Merge + dm.forEach((providers, depName) => { + if (!depsMap.has(depName)) depsMap.set(depName, new Set()) + providers.forEach((provider) => depsMap.get(depName)!.add(provider)) + }) + + // Check + const depsStoreShallowCopy = new Map(ctx.depsStore) + depsStoreShallowCopy.set(ctx.eslint.filename, depsMap) + checkCircularDependency(depsStoreShallowCopy, ctx, callExpr) + } + }) + + // Overwrite the historical dependency data + ctx.depsStore.set(ctx.eslint.filename, depsMap) +} + +/** + * Check the set of all function names in Context + * + * (which is called in the nested scopes of ArtalkPlugin arrow-function scope, excluding the top-level) + */ +const checkNestedCtxRefsNoTop = ( + ctx: ArtalkPluginCheckerContext, + m: Map, +) => { + m.forEach((methodName, node) => { + // Disallow life-cycle events in nested blocks + if (methodName === 'on') { + // Get the call arguments + const parent = node.parent + if (!parent || parent.type !== 'CallExpression') return + if (parent.arguments.length == 0) return + const eventNameArg = parent.arguments[0] + if (eventNameArg.type !== 'Literal') return + const eventName = eventNameArg.value + if (typeof eventName !== 'string') return + if (ctxLifeCycleEvents.includes(eventName)) { + ctx.eslint.report({ + node: parent, + messageId: 'noLifeCycleEventInNestedBlocks', + data: { + eventName, + }, + }) + } + } + + // Disallow inject in nested blocks + if (methodName === 'inject') { + ctx.eslint.report({ + node: node.parent || node, + messageId: 'noInjectInNestedBlocks', + }) + } + }) +} + +/** + * Check the set of all function names in Context + * + * (which is called in the watchConf effect function scope) + */ +const checkWatchConfCalls = (ctx: ArtalkPluginCheckerContext, m: Map) => { + const disallowedMethods = [...ctxEventFns] + m.forEach((methodName, node) => { + if (disallowedMethods.includes(methodName)) { + ctx.eslint.report({ + node: node.parent || node, + messageId: 'noEventInWatchConf', + data: { functionName: `ctx.${methodName}` }, + }) + } + }) +} + +/** + * Check the ArtalkPlugin variable declaration + */ +export const checkPluginFunction = ( + ctx: ArtalkPluginCheckerContext, + pluginFn: TSESTree.ArrowFunctionExpression, +) => { + // Get the first parameter name as the Context reference + if (pluginFn.params.length === 0) return // No ctx reference + const ctxArg = pluginFn.params[0] + if (ctxArg.type !== 'Identifier') return + const ctxArgName = ctxArg.name + + // Visit the top-level scope of the ArtalkPlugin arrow-function + const pluginFnScope = ctx.eslint.sourceCode.getScope(pluginFn.body) + const topLevelCtxRefs = getCtxRefNamesInTopScope(ctxArgName, pluginFnScope) + checkTopLevelCtxRefs(ctx, topLevelCtxRefs) + + // Visit all nested scopes (including the top-level) of the ArtalkPlugin arrow-function + const nestedCtxRefsIncludeTop = getCtxRefNamesInNestedScope(ctxArgName, pluginFnScope, true) + checkNestedCtxRefs(ctx, nestedCtxRefsIncludeTop) + + // Visit all nested scopes (excluding the top-level) of the ArtalkPlugin arrow-function + const nestedCtxRefsExcludeTop = getCtxRefNamesInNestedScope(ctxArgName, pluginFnScope, false) + checkNestedCtxRefsNoTop(ctx, nestedCtxRefsExcludeTop) + + // Visit watchConf effect function scope + const watchConfCalls = new Map() + topLevelCtxRefs.forEach((v, k) => { + if (v === 'watchConf') { + // Get the watchConf call expression + const watchConfCall = k.parent + if (!watchConfCall || watchConfCall.type !== AST_NODE_TYPES.CallExpression) return + + // Get the watchConf effect function + if (watchConfCall.arguments.length < 2) return + const watchConfEffectFn = watchConfCall.arguments[1] + if ( + watchConfEffectFn.type !== 'ArrowFunctionExpression' && + watchConfEffectFn.type !== 'FunctionExpression' + ) + return + + // Get the references to Context in the watchConf effect function top scope + const scope = ctx.eslint.sourceCode.getScope(watchConfEffectFn.body) + getCtxRefNamesInTopScope(ctxArgName, scope).forEach((v, k) => watchConfCalls.set(k, v)) + } + }) + checkWatchConfCalls(ctx, watchConfCalls) +} + +export const checkInjectCallOutsideArtalkPlugin = ( + ctx: ArtalkPluginCheckerContext, + node: TSESTree.Identifier, +) => { + if (node.name !== 'inject') return + const parent = node.parent + if (parent.type !== 'MemberExpression') return + if (parent.object.type !== 'Identifier') return + if (!['ctx', 'context'].includes(parent.object.name)) return + + // traverse up to find the ArtalkPlugin arrow-function + let curr: TSESTree.Node | undefined = parent + let pluginFn: TSESTree.ArrowFunctionExpression | undefined + const visited = new Set() + while (curr) { + if (visited.has(curr)) break + visited.add(curr) + if (curr.type === 'ArrowFunctionExpression') { + pluginFn = curr + } + curr = curr.parent + } + + const fail = () => { + ctx.eslint.report({ + node, + messageId: 'noInjectOutsidePlugin', + }) + } + + // check if the ArtalkPlugin arrow-function is found + if (!pluginFn) return fail() + const varDecl = pluginFn.parent + if (varDecl.type !== 'VariableDeclarator') return fail() + const typeRef = varDecl.id.typeAnnotation?.typeAnnotation + if (!typeRef || typeRef.type !== 'TSTypeReference') return fail() + const typeNameId = typeRef.typeName + if (typeNameId.type !== 'Identifier') return fail() + if (typeNameId.name !== 'ArtalkPlugin') return fail() +} diff --git a/ui/eslint-plugin-artalk/src/artalk-plugin.test.ts b/ui/eslint-plugin-artalk/src/artalk-plugin.test.ts index 2ddde59f..75329b69 100644 --- a/ui/eslint-plugin-artalk/src/artalk-plugin.test.ts +++ b/ui/eslint-plugin-artalk/src/artalk-plugin.test.ts @@ -32,6 +32,50 @@ const invalid = [ `, errorId: 'noEventInWatchConf', }, + { + name: "should not allow 'inject' call in nested blocks", + code: ` + import type { ArtalkPlugin } from 'artalk' + + export const TestPlugin: ArtalkPlugin = (ctx) => { + const fn = () => { + const foo = ctx.inject('foo') + } + } + `, + errorId: 'noInjectInNestedBlocks', + }, + { + name: "should not allow 'inject' call outside ArtalkPlugin", + code: ` + function fn(ctx) { + const foo = ctx.inject('foo') + } + `, + errorId: 'noInjectOutsidePlugin', + }, + { + name: 'should not allow circular dependency providing', + code: ` + import type { ArtalkPlugin } from 'artalk' + + export const TestPlugin: ArtalkPlugin = (ctx) => { + ctx.provide('foo', (foo) => {}, ['foo']) + } + `, + errorId: 'noCycleDeps', + }, + { + name: 'should not allow multiple ArtalkPlugin in a single file', + code: ` + import type { ArtalkPlugin } from 'artalk' + + export const TestPlugin: ArtalkPlugin = (ctx) => {} + + export const TestPlugin2: ArtalkPlugin = (ctx) => {} + `, + errorId: 'onePluginPerFile', + }, ] for (const { name, code, errorId } of invalid) { diff --git a/ui/eslint-plugin-artalk/src/artalk-plugin.ts b/ui/eslint-plugin-artalk/src/artalk-plugin.ts index 5e809420..a683fc23 100644 --- a/ui/eslint-plugin-artalk/src/artalk-plugin.ts +++ b/ui/eslint-plugin-artalk/src/artalk-plugin.ts @@ -1,18 +1,13 @@ -import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils' -import type { ContextApi } from '../../artalk/src/types/context' +import type { Context } from '../../artalk' +import { + isPluginName, + checkPluginFunction, + checkInjectCallOutsideArtalkPlugin, +} from './artalk-plugin-checkers' import { createRule } from './helper' -type _ = ContextApi // for IDE jump-to-definition +type _ = Context // for IDE jump-to-definition -/** Whether the given string is a ArtalkPlugin name */ -function isPluginName(s: string) { - return s === 'ArtalkPlugin' || /Artalk[A-Z0-9].*Plugin/.test(s) -} - -/** The event function names in ContextApi */ -const ctxEventFns = ['off', 'on', 'trigger'] - -/** The life-cycle event names in ContextApi */ -const ctxLifeCycleEvents = ['mounted', 'destroyed', 'updated', 'list-fetched'] +const depsMap: DepsStore = new Map() export const artalkPlugin = createRule({ name: 'artalk-plugin', @@ -20,257 +15,85 @@ export const artalkPlugin = createRule({ type: 'problem', docs: { description: - 'Enforce best practices for ArtalkPlugin arrow functions, including ContextApi usage.', + 'Enforce best practices for ArtalkPlugin arrow functions, including Context usage.', }, messages: { noLifeCycleEventInNestedBlocks: 'The life-cycle event `{{ eventName }}` listeners should only be defined in the top-level scope of the ArtalkPlugin.', noEventInWatchConf: 'Avoid calling `{{ functionName }}` inside the `ctx.watchConf` effect.', + noInjectInNestedBlocks: + 'The `ctx.inject` method should only be called in the top-level scope of the ArtalkPlugin.', + noInjectOutsidePlugin: + 'The `ctx.inject` method should only be called inside the ArtalkPlugin arrow function.', + noCycleDeps: 'Dependency cycle via `ctx.provide` ({{ route }}) in the ArtalkPlugin.', + onePluginPerFile: 'There is more than one ArtalkPlugin in this file.', }, schema: [], }, defaultOptions: [], create(context) { - // Initialize the TypeScript parser services - const parserServices = context.sourceCode.parserServices - if (!parserServices || !parserServices.program) { - console.error('[eslint-plugin-artalk] Missing typescript parser services.') - return {} - } - const checker = parserServices.program.getTypeChecker() - - // ------------------------------------------------------------------- - // Utility functions - // ------------------------------------------------------------------- - const getTypeName = (node: TSESTree.Node) => { - const tsNode = parserServices?.esTreeNodeToTSNodeMap?.get(node) - const tsType = tsNode ? checker.getTypeAtLocation(tsNode) : null - const typeName = tsType ? checker.typeToString(tsType) : '' - return typeName - } - - const getArrowFunctionType = (node: TSESTree.Node) => { - if (node.type === 'ArrowFunctionExpression') return getTypeName(node) - return '' - } - - const isInsideArtalkPlugin = (node: TSESTree.Node) => { - let curr: TSESTree.Node | undefined = node - while (curr) { - if (isPluginName(getArrowFunctionType(curr))) return true - curr = curr.parent - } - return false - } - - /** - * Get the references to ContextApi in the top scope of the given scope - */ - const getCtxRefNamesInTopScope = (ctxArgName: string, scope: TSESLint.Scope.Scope) => { - const ctxRefs = new Map() - - const getFullMethodName = (node: TSESTree.Node) => { - const methodNameArr: string[] = [] - let curr: TSESTree.Node | undefined = node - while (curr) { - if (curr.type === 'MemberExpression' && curr.property.type === 'Identifier') - methodNameArr.push(curr.property.name) - curr = curr.parent - } - return methodNameArr.join('.') - } - - scope.references.forEach((reference) => { - const identifier = reference.identifier - if (identifier.name !== ctxArgName) return - - const methodName = getFullMethodName(identifier.parent) - if (methodName) ctxRefs.set(identifier.parent, methodName) - }) - - return ctxRefs - } - - /** - * Get the references to ContextApi in the nested scopes of the given - */ - const getCtxRefNamesInNestedScope = ( - ctxArgName: string, - parentScope: TSESLint.Scope.Scope, - keepTop = true, - ) => { - const ctxRefs = new Map() - keepTop && - getCtxRefNamesInTopScope(ctxArgName, parentScope).forEach((v, k) => ctxRefs.set(k, v)) - parentScope.childScopes.forEach((childScope) => { - getCtxRefNamesInNestedScope(ctxArgName, childScope).forEach((v, k) => ctxRefs.set(k, v)) - }) - return ctxRefs + const checkerContext: ArtalkPluginCheckerContext = { + eslint: context, + depsStore: depsMap, } - // ------------------------------------------------------------------- - // Checker functions - // ------------------------------------------------------------------- - - /** - * Check the set of all function names in ContextApi - * - * (which is called in the top-level of ArtalkPlugin arrow-function scope) - */ - const checkTopLevelCtxRefs = (m: Map) => { - // console.debug('checkTopLevelCtxFnCalls', m.values()) - // ... - } - - /** - * Check the set of all function names in ContextApi - * - * (which is called in the nested scopes of ArtalkPlugin arrow-function scope) - */ - const checkNestedCtxRefs = (m: Map) => { - // console.debug('checkAllCtxFnCalls', m.values()) - // ... - // TODO: Event Circular trigger Check - } - - /** - * Check the set of all function names in ContextApi - * - * (which is called in the nested scopes of ArtalkPlugin arrow-function scope, excluding the top-level) - */ - const checkNestedCtxRefsNoTop = (m: Map) => { - m.forEach((methodName, node) => { - // Disallow life-cycle events in nested blocks - if (methodName === 'on') { - // Get the call arguments - const parent = node.parent - if (!parent || parent.type !== 'CallExpression') return - if (parent.arguments.length == 0) return - const eventNameArg = parent.arguments[0] - if (eventNameArg.type !== 'Literal') return - const eventName = eventNameArg.value - if (typeof eventName !== 'string') return - if (ctxLifeCycleEvents.includes(eventName)) { - context.report({ - node: parent, - messageId: 'noLifeCycleEventInNestedBlocks', - data: { - eventName, - }, - }) - } - } - }) - } - - /** - * Check the set of all function names in ContextApi - * - * (which is called in the watchConf effect function scope) - */ - const checkWatchConfCalls = (m: Map) => { - const disallowedMethods = [...ctxEventFns] - m.forEach((methodName, node) => { - if (disallowedMethods.includes(methodName)) { - context.report({ - node: node.parent || node, - messageId: 'noEventInWatchConf', - data: { functionName: `ctx.${methodName}` }, - }) - } - }) - } - - /** - * Whether the ArtalkPlugin is imported - * - * (to enable the plugin checker) - */ - let pluginCheckerEnabled = false + let lastPluginFilePath = '' + let lastPluginName = '' return { - ImportDeclaration(node) { - // Check if contains ArtalkPlugin importing - node.specifiers.forEach((specifier) => { - if (specifier.type !== 'ImportSpecifier') return - if (isPluginName(specifier.imported.name)) { - pluginCheckerEnabled = true - } - }) - }, + ImportDeclaration(node) {}, + + TSTypeAnnotation(node) { + const typeAnnotation = node.typeAnnotation + if (typeAnnotation.type !== 'TSTypeReference') return + if (typeAnnotation.typeName.type !== 'Identifier') return + const typeName = typeAnnotation.typeName.name + const identifier = node.parent - VariableDeclaration(fnNode) { - if (!pluginCheckerEnabled) return + if (isPluginName(typeName)) { + if (identifier.type !== 'Identifier') return + const pluginName = identifier.name - // Check if the variable declaration is ArtalkPlugin - fnNode.declarations.forEach((decl) => { + // Get the variable declaration of the ArtalkPlugin + const varDecl = identifier.parent if ( - isPluginName(getTypeName(decl)) && - decl.init && - decl.init?.type == 'ArrowFunctionExpression' + varDecl.type === 'VariableDeclarator' && + varDecl.init && + varDecl.init?.type == 'ArrowFunctionExpression' ) { - // Is ArtalkPlugin arrow-function - const pluginFn = decl.init - - // Get the first parameter name as the ContextApi reference - if (pluginFn.params.length === 0) return // No ctx reference - const ctxArg = pluginFn.params[0] - if (ctxArg.type !== 'Identifier') return - const ctxArgName = ctxArg.name - - // Visit the top-level scope of the ArtalkPlugin arrow-function - const pluginFnScope = context.sourceCode.getScope(pluginFn.body) - const topLevelCtxRefs = getCtxRefNamesInTopScope(ctxArgName, pluginFnScope) - checkTopLevelCtxRefs(topLevelCtxRefs) - - // Visit all nested scopes (including the top-level) of the ArtalkPlugin arrow-function - const nestedCtxRefsIncludeTop = getCtxRefNamesInNestedScope( - ctxArgName, - pluginFnScope, - true, - ) - checkNestedCtxRefs(nestedCtxRefsIncludeTop) - - // Visit all nested scopes (excluding the top-level) of the ArtalkPlugin arrow-function - const nestedCtxRefsExcludeTop = getCtxRefNamesInNestedScope( - ctxArgName, - pluginFnScope, - false, - ) - checkNestedCtxRefsNoTop(nestedCtxRefsExcludeTop) - - // Visit watchConf effect function scope - const watchConfCalls = new Map() - topLevelCtxRefs.forEach((v, k) => { - if (v === 'watchConf') { - // Get the watchConf call expression - const watchConfCall = k.parent - if (!watchConfCall || watchConfCall.type !== AST_NODE_TYPES.CallExpression) return - - // Get the watchConf effect function - if (watchConfCall.arguments.length < 2) return - const watchConfEffectFn = watchConfCall.arguments[1] - if ( - watchConfEffectFn.type !== 'ArrowFunctionExpression' && - watchConfEffectFn.type !== 'FunctionExpression' - ) - return - - // Get the references to ContextApi in the watchConf effect function top scope - const scope = context.sourceCode.getScope(watchConfEffectFn.body) - getCtxRefNamesInTopScope(ctxArgName, scope).forEach((v, k) => - watchConfCalls.set(k, v), - ) - } - }) - checkWatchConfCalls(watchConfCalls) + // console.log('Found ArtalkPlugin:', pluginName) + checkPluginFunction(checkerContext, varDecl.init) + + // check for multiple ArtalkPlugins in the same file + if (lastPluginFilePath === context.filename && lastPluginName !== pluginName) { + context.report({ + node: identifier, + messageId: 'onePluginPerFile', + }) + } + lastPluginFilePath = context.filename + lastPluginName = pluginName } - }) + } }, - Identifier(node) {}, + VariableDeclaration(fnNode) {}, - CallExpression(node) {}, + Identifier(node) { + if (node.name === 'inject') { + checkInjectCallOutsideArtalkPlugin(checkerContext, node) + } + }, } }, }) + +type ArtalkPluginRuleContext = Parameters<(typeof artalkPlugin)['create']>[0] + +export type DepsData = Map> // DepName -> ProviderNames +export type DepsStore = Map // FilePath -> DepsData + +export interface ArtalkPluginCheckerContext { + eslint: ArtalkPluginRuleContext + depsStore: DepsStore +} diff --git a/ui/eslint-plugin-artalk/src/scc.ts b/ui/eslint-plugin-artalk/src/scc.ts new file mode 100644 index 00000000..9be78b7b --- /dev/null +++ b/ui/eslint-plugin-artalk/src/scc.ts @@ -0,0 +1,74 @@ +type Graph = Map> // Graph with nodes and their dependencies +type SCC = Set[] // Array of Strongly Connected Components + +/** + * Tarjan's Algorithm to find Strongly Connected Components (SCCs) in a directed graph. + * + * The function uses a depth-first search (DFS) approach to discover SCCs in the input graph. + * It keeps track of discovery times and low-link values for each node, which helps in detecting cycles. + * Once SCCs are found, they are added as sets to the result array. + * + * @param graph - A directed graph represented as a Map where the keys are nodes and values are arrays of adjacent nodes. + * @returns An array of Sets, where each Set contains the nodes of one Strongly Connected Component. + * @link https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm#The_algorithm_in_pseudocode + */ +export function tarjan(graph: Graph): SCC { + const indices = new Map() // To store the discovery index of each node + const lowlinks = new Map() // To store the lowest point reachable from each node + const onStack = new Set() // To keep track of nodes currently on the stack + const stack: string[] = [] // Stack to simulate recursion and track SCC nodes + const scc: SCC = [] // Result array to store SCCs + let idx = 0 // Global index counter + + /** + * Strongly connects a node by performing DFS, updating the indices and lowlinks. + * Once an SCC is found (when a node's lowlink equals its index), it's popped off the stack. + * + * @param v - The current node being explored in the DFS + */ + function strongConnect(v: string): void { + // Set the discovery index and lowlink for the node + indices.set(v, idx) + lowlinks.set(v, idx) + idx++ + stack.push(v) + onStack.add(v) + + // Explore the neighbors (dependencies) of the current node + const deps = graph.get(v) || [] // Get the adjacent nodes (or an empty array if no edges) + for (const dep of deps) { + if (!indices.has(dep)) { + // If the neighbor hasn't been visited, recursively explore it + strongConnect(dep) + lowlinks.set(v, Math.min(lowlinks.get(v)!, lowlinks.get(dep)!)) + } else if (onStack.has(dep)) { + // If the neighbor is on the stack, update the lowlink of the current node + lowlinks.set(v, Math.min(lowlinks.get(v)!, indices.get(dep)!)) + } + } + + // If the current node is a root node (its lowlink equals its index), it forms an SCC + if (lowlinks.get(v) === indices.get(v)) { + const vertices = new Set() + let w: string | undefined = undefined + // Pop all nodes off the stack until we return to the current node + while (v !== w) { + w = stack.pop()! + onStack.delete(w) + vertices.add(w) + } + // Add the SCC to the result + scc.push(vertices) + } + } + + // Start DFS on all nodes that haven't been visited yet + for (const v of graph.keys()) { + if (!indices.has(v)) { + strongConnect(v) + } + } + + // Return the list of SCCs + return scc +} diff --git a/vitest.workspace.ts b/vitest.workspace.ts index bf9b2801..044e513d 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -1,11 +1,11 @@ import { defineWorkspace } from 'vitest/config' export default defineWorkspace([ - "./ui/artalk/vitest.config.ts", - // "./docs/landing/vite.config.ts", - // "./test/vue-test/vite.config.ts", + './ui/artalk/vitest.config.ts', // "./ui/plugin-katex/vite.config.ts", // "./ui/plugin-auth/vite.config.ts", // "./ui/plugin-lightbox/vite.config.ts", // "./ui/artalk-sidebar/vite.config.ts" + // "./docs/landing/vite.config.ts", + // "./test/vue-test/vite.config.ts", ])